Skip to content

Powered by Grav CMS + Helios Theme

Configuration

This page lists every KahunaCart config key, its default, and what it does. Set them in user/config/plugins/kahunacart.yaml.

Most keys also have a field on the admin's Settings screen, which is part of the KahunaCart page since 1.2.0. Keys marked YAML-only have no admin field. See Orders and the admin → Settings, on this page.

Clear the route cache after a plugin update

The API plugin caches its route table on disk under cache://api/. Installing, enabling, disabling or removing a plugin clears that cache. Updating a plugin that is already enabled does not, so its new API endpoints return 404 while the admin screens look installed.

After updating KahunaCart or any provider plugin, clear the cache:

BASH
bin/grav clear-cache

To drop only the route table, delete the directory:

BASH
rm -rf cache/api

Note

Route caching is skipped while system.debugger.enabled is true, so the problem appears in production and not in development.

Environment variables

Every key on this page accepts Grav's environment-variable form. A value of $MY_VAR or ${MY_VAR} is replaced with that variable from the server's environment when the configuration loads. Use it for anything that should not sit in a committed file: a provider's API key, a database password, the signing secret of a notification channel.

YAML
database:
  type: mysql
  password: $KAHUNACART_DATABASE_PASSWORD
checkout:
  address_validation:
    api_key: $MAPBOX_TOKEN

Set the variable in the web server's or PHP-FPM's environment (env[KAHUNACART_DATABASE_PASSWORD] in a pool file, SetEnv in Apache) and in the shell the scheduler runs from, or bin/plugin kahunacart work reads an empty value. A variable that is not set resolves to an empty string, not to the literal $NAME, so it behaves like an empty setting. Provider plugins read their settings through the same loader, so their keys take the form too.

The name to use

Any variable name works. The convention below is the one bin/plugin kahunacart config:export writes, so an export drops into another store and reads the right variables without editing:

$KAHUNACART_ followed by the key in upper case, with every run of non-alphanumeric characters replaced by a single underscore.

Setting Environment variable
database.password $KAHUNACART_DATABASE_PASSWORD
checkout.address_validation.api_key $KAHUNACART_CHECKOUT_ADDRESS_VALIDATION_API_KEY
notifications.slack.webhook_secret $KAHUNACART_NOTIFICATIONS_SLACK_WEBHOOK_SECRET

Which settings the exporter treats as secret

config:export never writes a secret’s value. The store signing secret, UCP signing keys and sign-in nonces are omitted. Import preserves the target store’s credentials and ignores credential values in older files. Notification webhook URLs and Google’s order webhook URL leave as environment references. A config key counts as secret when its full dotted name contains any of these words:

api_key, secret, password, token, private_key, credential, webhook_secret, passphrase

Those come out as $KAHUNACART_… references. Everything else comes out as its value. The rule reads the key's name, not a list of known keys, so a provider plugin's own stripe.api_key is covered too.

Warning

The rule is a good default, not a guarantee. A secret stored under a name matching none of those words, merchant_id say, exports as a value. Read an export before you commit it.

See CLI for the export and import commands.

Store

Key Default Effect
enabled true Master switch. Off means no storefront routes, no admin screen, no API endpoints.
route /shop Base route for the storefront. Every other storefront route lives under it: /shop/cart, /shop/product/{slug}, /shop/webhook/{slug}. / makes the shop the site's home page; see Storefront → Mounting the shop at the site root. Changing it changes your webhook URLs, so update them at every provider.
currency USD ISO 4217 code. Catalog prices and orders use it. Stored once per order, so changing it later does not rewrite history. Exactly 3 characters.
order_number_start 1000 First sequential order number. Numbers come from an atomic database sequence, so they never collide or repeat.
builtin_css true Enqueues the shipped storefront stylesheet on KahunaCart routes. Set to false when your theme owns all storefront styling. See Storefront → Styling and Theming.
base_template 'partials/base.html.twig' Which of the theme's shells the storefront pages render inside. Point this at the theme's plain shell, not a documentation shell with a sidebar. YAML-only.
store.status open Whether the store takes orders: open, coming_soon or paused. Anything else reads as open, so a typo never closes a shop. See Store status below.
store.status_message '' What the banner and every refusal say while the store isn't open, up to 500 characters. Empty uses the standard sentence for the status, translated. It can carry a link written [text](/url).

Store status

One setting opens the store, announces it, or pauses it, so you never have to switch the cart, the theme's cart link, express pay and the agent channels off one by one. Change it under Store status on the General tab of KahunaCart's settings, with the switch at the top of the store dashboard, with bin/plugin kahunacart store --status=paused, or through the admin API (GET and POST /kahunacart/store/status, and the MCP tools get_store_status and set_store_status).

  • Open (open) takes orders as normal.
  • Coming soon (coming_soon) is for a store that hasn't launched. Nobody but staff can buy, and search engines are asked to leave the storefront out: every storefront page carries noindex (a robots meta tag and an X-Robots-Tag header), its products drop out of the sitemap, and the Google and OpenAI product feeds answer 404.
  • Paused (paused) is for a store that is closed for a while: a move to another site, a stock-take, a holiday. Nobody but staff can buy, and nothing about indexing changes, so a short pause doesn't cost the store its search rankings or its merchant centre listings.

While the store is coming soon or paused:

  • Every page shows a banner with your message, the site's other pages included. The plugin puts it straight after <body>, or your theme places it itself. See Theming → The store status.
  • Buying is off everywhere. The cart and checkout pages, adding to the cart, buy-now, moving a wishlist into the cart, the restore page a recovery or share link opens, express pay, the storefront JSON API's cart and checkout, the storefront MCP's cart and checkout tools, and UCP checkout all refuse with your message. The cart and checkout pages answer 503 with Retry-After: 3600, so search engines read it as "come back later". Moving a wishlist into the cart is refused with a message on the wishlist page. Reading the cart through the JSON API and cancelling an open payment still work. The JSON API, MCP and UCP answer a store_closed error. Product pages still show, with Coming soon or Paused where the buy button was.
  • Everything else keeps working: browsing and search, customer sign-in and account pages (a wishlist can still be kept, shared and edited), order and receipt pages, downloads already bought, invoices, pay links for a balance or a deposit on an existing order, accepting a quote, provider webhooks, the job worker, subscription renewals, and the whole admin. Paying for an order that already exists isn't buying.
  • Staff can still buy. A signed-in admin with KahunaCart permissions (kahunacart.orders.view, kahunacart.settings, or a super admin) sees the store working, with a banner saying "Your store is paused. Only staff can buy." and what shoppers are told. That is how you place a test order before you open. An agent, UCP, or an API client with a token-bound cart never gets the staff view. A storefront JSON API request made with a signed-in staff session can still buy.
  • You won't forget to reopen. The dashboard shows the status in the warning colour, and bin/plugin kahunacart check warns about it every time it runs. See Going live → Store status.
  • Nothing closed sits in a cache. Every page that would otherwise be cached, in the shop and on the rest of the site, goes out as Cache-Control: private, no-cache, max-age=0, while the cart, checkout, receipt and account pages keep their private, no-store, so no CDN or proxy keeps a copy and a browser asks again before showing one. An open store keeps the caching it always had.

Every change clears Grav's cache, the same as saving the settings in the admin, so the next request reads the new status. A page a browser or a CDN kept from while the store was open can still show a buy button until its max-age runs out (a week with Grav's default system.pages.expires). That does no harm, because the cart and checkout refuse the purchase whatever the page shows. If a CDN sits in front of the shop, purge it after you change the status.

Changing the status fires onKahunaCartStoreStatusChanged with from, to, message and by. Changing only the message fires nothing. The owner notifications don't include it, because the person who changes the status is the person who would be told.

The status belongs to each site. A store bundle and config:export never carry it, and a restore or config:import never writes it, so a store paused for its move doesn't arrive paused, and a new store paused for its test order isn't opened by the restore. See Export and restore → Moving your shop to its own site.

cart.enabled: false is a different thing. It removes the basket from a store that only shows a catalogue, with no banner and no way in for staff. A paused store keeps its cart, ready for the day it opens.

Currencies

The currency key above is the base currency. The currencies block lists any extra currencies the store sells in and how a visitor's currency is chosen. Leave it alone and the store is single-currency, exactly as before. How it all works is on Currencies.

Key Default Effect
currencies.enabled [] Extra currencies besides the base, as ISO 4217 codes: [EUR, GBP]. Empty stops new carts in extra currencies. Existing carts, orders and saved amounts keep their currencies. The base is ignored if listed. An invalid code is left out and noted in the Grav log.
currencies.detect [choice, address, header] Steps tried when no cart with lines decides the currency: an explicit URL or cookie choice, a signed-in customer’s address, then the country header. Leave a step out to disable it. The base currency is the last choice. Public pages do not send a currency cookie.
currencies.header empty The request header carrying a country code, such as CF-IPCountry. Empty switches the header step off.
currencies.map {} Country to currency, as EUR: [DE, FR, NL] and GBP: [GB]. Ships empty. A country the map does not name is in the base currency, and a currency that isn't in currencies.enabled is ignored.
currencies.lock_to_country false On, the country on the order decides the currency at checkout, even when the shopper picked another. See Letting the shipping country decide.
currencies.missing_price unavailable With unavailable, each currency chooses typed or converted pricing on its own. convert makes every enabled currency that has a rate convert its missing amounts from the base, keeping its markup and rounding. A typed amount always wins, and with no rate there is no conversion. See Converted prices.
currencies.rates.source manual Where the exchange rates between your currencies come from: manual (you type each one on the Currencies screen), ecb (the European Central Bank's daily rates, free, no key), frankfurter (central banks' daily rates for over 160 currencies, free, no key) or the code of a source an add-on registered. With anything but manual the worker fetches the rates once a day. See Exchange rates.
currencies.rates.fallback frankfurter The source asked for the rates your source doesn't answer, and for all of them when your source can't be reached. none (or empty) means no fallback. A fallback that is the source itself is ignored, and nothing reads it on the manual source. See Where the rates come from.
currencies.rates.frankfurter_url https://api.frankfurter.dev/v2 Where Frankfurter's v2 API is, for a store that runs its own copy. It has to be reachable over https at a public address.
currencies.rates.frankfurter_providers empty One Frankfurter provider id, such as ECB or NBP, to use that bank's own rate instead of Frankfurter's blend.
currencies.rates.hold_move_percent '10' How far, in percent, a fetched rate may move from the one in use before a refresh holds it for you to accept or turn down, instead of writing it. A quoted decimal string, never less than 2. Replaces max_change_factor, which is no longer read. See A big move is held for you to check.
currencies.rates.manual_drift_percent '5' How far, in percent, a rate you typed may be from the market rate before the dashboard shows an information line. A quoted decimal string. Your typed rate is always the one used. See Typing a rate.
currencies.rates.max_age_days 3 How many working days (Monday to Friday) past the day its source published it a fetched rate may get before the dashboard shows an alert and the owner notification goes out. At least 1. A rate you typed never counts as old.
currencies.pricing {} Pricing choices for each currency, saved on the Currencies screen. No entry means typed pricing, zero markup and no extra rounding.
currencies.pricing.<CODE>.mode manual manual uses typed amounts. converted fills missing amounts from the base amount with the rate. A typed amount always wins.
currencies.pricing.<CODE>.markup_percent '0' Markup on converted money amounts, as a quoted decimal string from -90 to 1000 with up to four decimal places.
currencies.pricing.<CODE>.rounding none none, whole, 99, 95 or nearest_005. Prices use the rule after rounding to the currency's decimal places. Discounts, deposits, thresholds and fees round to the decimal places only. Percentages never convert. See What is rounded.

A currency set to convert also converts a missing entry in a config map from the base entry (or from a plain base amount), and an entry you typed still wins. Save pricing on the Currencies screen to queue the index update. After changing it directly in YAML, run bin/plugin kahunacart index --rebuild.

shipping.flat_rate, cart.order_min_total and cart.order_max_total take a plain base-currency amount or a map of currency to amount. On a store that sells in more than one currency, Configuration has one box per currency for each of the three. A store with one currency keeps a single box. With typed pricing, a missing amount is never filled from the base. A currency set to convert fills it in. Return fees and shipping policy amounts also accept maps. See Currencies → Amounts in the config file.

Product images and media

Product images live in user/images/kahunacart/products/{productId}, which the web server serves directly. Video, audio and PDF samples share that directory. KahunaCart reads an upload's format from the file, never from its extension.

All four keys are on the General tab of the plugin settings, under Product Media.

Key Default Effect
images.max_upload_mb 8 Largest single product image the admin upload endpoint accepts.
images.derivatives true Lets Grav generate resized copies for cards and thumbnails. false serves the originals.
media.max_upload_mb 200 Largest single video, audio, or PDF the admin accepts. Pictures keep their own smaller limit. PHP's upload_max_filesize and post_max_size still apply and are usually the lower limit.
media.embed_hosts youtube.com, youtu.be, youtube-nocookie.com, vimeo.com, player.vimeo.com The video hosts a merchant may paste an embed URL from. Anything else is refused with a 422. The storefront loads nothing from these hosts until a shopper presses play.

Storefront SEO

These apply to product and category pages only.

Key Default Effect
seo.enabled true Emits JSON-LD, meta description, Open Graph, and Twitter cards.
seo.canonical false Emits <link rel="canonical">. Kahuna and Quark2 emit their own, so enable this only for a theme that does not.
seo.sitemap true Contributes product and category URLs to the Sitemap plugin when it is installed.
seo.sitemap_cache 3600 How long, in seconds, the sitemap contribution is kept in Grav's cache. Without it every request for /sitemap.xml reads every published product and category, once per language on a multilanguage store. 0 asks the catalog every time, and bin/grav clear empties it.

Four more seo.* keys add the structured data agents and shopping search read. They only take effect while agentic.enabled is on; see Agentic commerce.

Key Default Effect
seo.organization true An OnlineStore block on the shop's front page, with the seller's name, logo, support contact and return policy.
seo.shipping_details true OfferShippingDetails on each offer, from policies.shipping_summary. Nothing when the summary is empty or the product does not ship.
seo.return_policy true MerchantReturnPolicy on each offer, from policies.returns. Nothing until policies.refund_url is set.
seo.variant_markup aggregate aggregate is one Product with one offer. group emits a ProductGroup with one Product per variant, each with its own URL, identifiers, picture and offer.

See Storefront → Search engines for what each block contains.

Catalog

These eight keys decide how the shop page, category pages and search present the catalog. All eight are on the General tab of the plugin settings, under Catalog Order. The catalog index and the merchandising blocks have their own subsections below.

Key Default Effect
catalog.sort newest The order listings come back in: newest, oldest, manual, title, price_asc or price_desc. Anything unrecognized reads as newest. See Products and catalog.
catalog.featured_first true Puts featured products at the top of every listing, whatever catalog.sort says. false ignores the flag on the storefront without unmarking anything.
catalog.per_page 24 How many products one page of a listing holds. Applies to the shop page, category pages, tag and attribute filtered results, ?sale=1 and search. 0 turns paging off and the listing stops at search.limit instead. Curated catalog.sections blocks are never paginated. Admin label Products Per Page. See Storefront.
catalog.default_manufacturer '' The manufacturer id every product shows in its Product safety section when it names none of its own. Empty means none. Set on Settings → General → Default Manufacturer, which lists the store's manufacturers by name. A shop that makes its own goods sets it once. See Products and catalog → Product safety (GPSR).
catalog.filters auto Whether listings draw the filter panel: auto, on or off. off also ignores the filter parameters in the URL. Anything unrecognized reads as auto. See Storefront.
catalog.facets.max_values 12 How many tick boxes one attribute facet draws before the rest go behind a Show all N toggle. The most common values are shown, and whatever a shopper has ticked stays visible. 0 draws every value. Admin label Values Per Facet, range 0 to 500. See Storefront.
catalog.facets.empty grey What the panel does with a value nothing reachable answers: grey leaves it in place, disabled, with its 0; hide takes it out. Anything unrecognized reads as grey. Admin label Values That Match Nothing. See Storefront.
catalog.facet_cache 60 How long, in seconds, the counts beside the filter tick boxes are cached. Only counts go stale: a tag's count, an attribute value's count, whether a facet has anything in it, and the category tree numbers. Products, prices, stock, the cart and the checkout are read fresh on every request. 0 asks the database on every listing page, and bin/grav clear empties the cache.
catalog.sections [] The shop page as a sequence of blocks instead of one grid. Empty renders the flat listing. At most 20 blocks. See Storefront.

Note

Write filters: 'off' with the quotes in a hand-edited YAML file. An unquoted off is a boolean to a YAML 1.1 reader. KahunaCart reads that boolean as off and an unquoted on as on, so nothing breaks, but the quoted form says what it means.

The catalog index

The catalog index is one row per purchasable unit behind every listing, facet count, sort and search. See The catalog index.

Key Default Effect
catalog.index.enabled true Whether the index may answer a listing at all. false puts every listing, facet count, sort and search back on the live-query path, which returns the same rows and is slower on a large catalog.
catalog.index.mode auto When the index is joined. auto joins it only when a sale, a price list or a search term needs it. always joins it on every filtered listing, which suits a large catalog with no promotions.
catalog.index.chunk 1000 How many products one index-rebuild transaction writes. Lower it on a server with a small transaction budget.
catalog.index.per_tick 500 How many products the once-a-minute work tick rebuilds. What a tick does not reach is still queued next minute. 0 never drains from the worker, for a store that runs bin/plugin kahunacart index --dirty from cron instead.

An index that has never been built is never joined, whatever these are set to. Run bin/plugin kahunacart index --rebuild once after updating. See CLI.

Key Default Effect
catalog.relations.cache 300 How long, in seconds, the list of products in a related, cross-sell or up-sell block is cached. Only the product list can go stale. Prices, sale badges and stock are read live. Saving a list refreshes it at once. 0 asks the database on every product page, and bin/grav clear empties the cache.
catalog.relations.limit 8 How many products one related, cross-sell or up-sell block renders. 0 renders every product the merchant picked.

Product revisions and scheduling

See Product lifecycle for what a revision holds and how a queued change is applied.

Key Default Effect
catalog.revisions.keep 20 How many revisions each product keeps. One is written on every save, and the work tick prunes each product back to this figure. 0 switches revisions off: no snapshot is taken and Restore has nothing to offer. A queued change is never pruned.
catalog.schedule.enabled true Whether the work tick publishes products whose publish_at has passed, takes down products whose unpublish_at has passed, and applies queued changes. false leaves both columns readable and stops acting on them.
Key Default Effect
search.limit 50 The ceiling on an unpaginated listing. Applies only when catalog.per_page is 0: the listing stops here and asks the shopper to narrow it down.
search.live true Results follow the search box as it is typed in, replacing the results region without a page reload. false leaves the plain form and its Search button, which a browser with no scripting gets either way. See Storefront.

Choose the search engine

search.products.driver is a ladder of four values, and auto takes the highest rung this installation can run.

Value Answers the search box Needs
auto The highest rung available. The default. Nothing.
yetisearch The BM25 file index, with typo tolerance. pdo_sqlite plus FTS5.
sql The catalog index's per-engine full-text search. The catalog index built and enabled.
like The portable substring scan. Nothing.

auto falls down the ladder instead of failing: yetisearch on a PHP build with no FTS5 lands on sql, then on like. A store pinned to like keeps its search pages on the live queries even when the catalog index is joined for every other listing. See Storefront.

Key Default Effect
search.products.driver auto Which engine answers the search box: auto, yetisearch, sql or like. See the ladder above.
search.products.fuzzy true When a yetisearch search finds nothing, run it again against the words the catalog contains, so rucksak finds the Rucksack. The sql and like rungs ignore it.
search.products.max_matches 500 How many ranked results one yetisearch search can page through. Between 1 and 5000. Ignored on the sql and like rungs.

Rank search results and apply synonyms

Key Default Effect
catalog.search.rank true Whether relevance leads the order on a search page: how many of the term's words are in the product's name, then the engine's own score, then catalog.sort as a tie-break. false orders search results by catalog.sort alone.
catalog.search.synonyms true Whether the merchant's synonym table is applied when a shopper searches. false ignores the table without deleting it.

Off by default. Switch it on and the ranked index finds products by meaning as well as keywords, so hat finds the cap and the beanie. It needs the yetisearch engine and an embedding provider, and it sends product text and search terms to that provider. Most stores set enabled and api_key and leave the rest alone. See Storefront → Semantic search.

Keys shown with a default in brackets ship empty. An empty key takes YetiSearch Pro's value when that plugin has its own semantic search on, and the bracketed default otherwise.

Key Default Effect
search.products.semantic.enabled false Rank by meaning as well as keywords. Off is exactly the keyword search, with nothing sent anywhere. Never taken from YetiSearch Pro.
search.products.semantic.provider '' (openai_compatible) Any service that speaks the OpenAI embeddings API: OpenAI, OpenRouter, Ollama, Voyage, Mistral.
search.products.semantic.base_url '' (https://api.openai.com/v1) Where the provider is. OpenRouter is https://openrouter.ai/api/v1, Ollama http://localhost:11434/v1.
search.products.semantic.api_key '' The provider's key, stored encrypted. env:NAME reads it from the environment variable NAME instead. Leave it empty for a local server such as Ollama.
search.products.semantic.model '' (text-embedding-3-small) The embedding model: openai/text-embedding-3-small on OpenRouter, nomic-embed-text on Ollama. Changing it re-embeds every product.
search.products.semantic.dimensions null (512) Length of each vector. Only OpenAI's text-embedding-3 models accept a value. Every other model needs 0, which uses the model's own length. Changing it re-embeds every product.
search.products.semantic.query_prefix '' Text some models want before a search term. nomic-embed-text wants search_query:. Changing it re-embeds every product.
search.products.semantic.document_prefix '' Text some models want before a product's text. nomic-embed-text wants search_document:. Changing it re-embeds every product.
search.products.semantic.weight null (0.5) How much of the ranking comes from meaning: 0 is keywords only, 1 is meaning only.
search.products.semantic.min_similarity null (0.25) Products less similar than this are never found on meaning alone. 0.25 suits OpenAI's text-embedding-3. nomic-embed-text needs around 0.5.
search.products.semantic.min_margin null (0.10) The noise gate: how far the best product card must stand above the median card before meaning adds any results, so asdf adds nothing. With calibration: auto the value measured on your catalog replaces it once there is one. Applies from ten products up.
search.products.semantic.calibration auto auto measures min_margin on your catalog after an embedding run. off uses min_margin exactly as set. Never taken from YetiSearch Pro. See CLI → search:calibrate.
search.products.semantic.calibration_strictness null (2.0) How far above the nonsense a calibrated gate sits, in standard deviations, from 0 to 5. Lower lets more borderline searches find products by meaning, and more nonsense with them. Takes effect on the next search.
search.products.semantic.relative_similarity null (0.85) A product found by meaning must be at least this share as similar as the best match.
search.products.semantic.query_frame '' (a {query}) The phrase a search term is embedded in, so hat is read as a thing to buy rather than a word. {query} alone embeds the term as typed. A store in another language can write the phrase in its own. Changing it re-embeds nothing.
search.products.semantic.min_query_chars null (3) Search terms with fewer letters and digits than this are keyword-only and never sent. Spaces and punctuation don't count, so hat qualifies and a b doesn't.
search.products.semantic.query_timeout null (5) Seconds a search waits for the provider before answering from keywords alone.
search.products.semantic.timeout null (20) Seconds the worker waits for the provider to embed one batch of products.
search.products.semantic.embed_after_indexing true Queue an embedding job after a reindex or a product save. Off, the worker's own tick still embeds anything waiting.
search.products.semantic.batch_limit 200 Products embedded per call to the provider.

Tax

The flat rate below is a fallback. As soon as one tax zone exists, the zone engine takes over, and tax.rate, tax.apply_to_shipping and tax.label stop being read. See Tax.

Key Default Effect
tax.rate '0' Flat percent as a string, such as '20' or '8.25'. Ignored once tax zones exist.
tax.included false Catalog prices already contain tax. Applies to both engines. The contained amount is back-calculated and shown on the receipt as an included adjustment that does not move the total.
tax.apply_to_shipping false Whether the flat rate taxes shipping too. Ignored by the zone engine, which decides this per rate.
tax.label 'Tax' Label on the flat-rate adjustment. Ignored by the zone engine, which uses each rate's own label.
tax.basis shipping Which address decides the zone: shipping or billing. The other address is the fallback when the chosen one has no country. This governs shipping zone matching too.
tax.adjust_price_when_disqualified false With inclusive pricing, take the baked-in tax back off the total for a customer who falls outside every tax zone. Turning it on changes the total a customer is shown. The removal is a negative adjustment of type tax carrying subtype: disqualified_inclusive, never a discount.
tax.engine '' The default tax engine for every zone that names none of its own, as a registered engine code. Empty is KahunaCart's own zone engine. KahunaCart ships no third-party engine. See Tax drivers.
tax.price_note false Print "incl. VAT" or "excl. VAT" under product prices, worked out the way checkout works out whether a price contains tax, and using the store's own name for the tax. Nothing is printed on a store that charges no tax. On the Commerce tab, under Tax, as Say whether prices include tax. See Tax → Saying so under the price.
tax.price_note_shipping false Add "plus shipping" to that note for products that ship.

tax.included is the bottom of a four-level order: the product's own price_includes_tax wins, then a region price preference, then a currency one, then this key. See Tax.

Shipping

The flat rate applies until the first shipping zone exists. See Shipping.

Key Default Effect
shipping.flat_rate '0' Decimal string in the store currency, e.g. '5.99'. 0 means no shipping charge. Applied to any order containing a physical item. Ignored once shipping zones exist. YAML-only. On a store that sells in more than one currency it may be a map of currency to amount, { USD: '5.99', EUR: '5.50' }. A currency with no entry has no flat rate, so the store does not ship there, and an entry of '0' means no charge in that currency.
shipping.label 'Shipping' Label on the flat-rate adjustment.
shipping.carriers [] Carriers offered when you record a shipment, on top of the sixteen built in. Rows of {code, label, tracking_url}, or a map of code to template. Reuse a built-in code to replace its tracking URL. Put {tracking} where the number goes. See Shipping.
shipping.pickup_enabled true Offer click and collect. Off stops offering the collection points and keeps them configured. See Pickup.
shipping.show_unavailable true Show a delivery method this cart cannot have, disabled and with the reason, instead of dropping it off the list. A merchant with many methods may want the shorter list. See Shipping.
shipping.metadata_enabled true Let a rate-calculator add-on's own details, such as the courier and the estimated day, reach the checkout page and the chosen method's adjustment snapshot. KahunaCart itself never writes any.
shipping.carrier_rates.enabled false Ask installed carrier drivers to price the methods bound to them. KahunaCart ships no carrier; the drivers come from the shipping add-on. See The carrier rate contract.
shipping.carrier_rates.timeout 5 How long one driver gets, in wall-clock seconds. A driver that overruns has its answer discarded and its methods shown unavailable, and checkout carries on with the fallback method.
shipping.carrier_rates.cache_ttl 900 How long a carrier quote is kept, in seconds. 0 asks the carrier every time. Saving a method's rates clears the cache, and so does the admin's rebuild action.
shipping.carrier_rates.fallback_method_id ~ (none) The offline method that carries the checkout when every carrier-priced method is unavailable. Its id, from the shipping zone editor.
shipping.packages.enabled true Pack an order by shipping profile and price each parcel separately. With the two built-in profiles that is one parcel per order. Off packs every physical line into one parcel and stores nothing.
shipping.expressions.enabled true Let a shipping method carry a cost expression. Off refuses to save one and ignores any already stored.

Sales

Automatic scheduled discounts. A store with no sales renders the storefront it always did. See Sales.

Key Default Effect
sales.overlap priority Which sale wins when two cover the same variant: priority (highest priority wins) or best_for_customer (deepest discount wins). YAML-only.

Price lists and quantity tiers

A price list is a named set of prices for the shoppers its rules name. A quantity tier is a price that depends on how many are being bought. See Price lists and quantity tiers.

Key Default Effect
pricing.enabled true Whether price lists and quantity tiers are resolved at all. false answers every price from the variant's own price without reading either table.

Important

Price lists have no storefront or checkout effect in this release. They are stored, resolved and administered, but nothing in the cart or the checkout reads them, so a list you build today changes no price a customer pays.

Coupons

Discount codes the customer types in, or arrives holding from a link such as /shop?coupon=EARLY50. See Coupons.

Key Default Effect
coupons.max_codes 5 How many discount codes one cart may carry. Codes apply in the order the customer added them, each working on what the ones before it left. 1 restores the single-code cart. A coupon marked Cannot combine with other coupons runs alone whatever this is set to. See Stacking codes.
coupons.link.enabled true Watch for a coupon code in the URL. The code is checked and held in the session on arrival, and put on the cart as soon as there is one.
coupons.link.param coupon The query parameter to read the code from. A redirect strips it from the URL so it is not bookmarked, shared or indexed.
coupons.link.days 7 How long a held code lives before it lapses. Capped at 365.
coupons.link.announce true Keep a banner up saying which code is active. Closing the banner does not remove the code.
coupons.link.preview true Draw the deduction on product cards where the code would apply.

Stock

Key Default Effect
stock.hold_minutes 15 How long a stock hold keeps stock reserved while a customer is away at a redirect payment provider. Holds expire on their own, so an abandoned payment never strands stock. 0 reserves nothing.
stock.digital_sold_out not_available What a product that needs no shipping says once its tracked stock reaches zero with backorders off: not_available ("Not available") or coming_soon ("Coming soon"). Products that ship always say "Out of stock". See When a download runs out.
stock.digital_sold_out_text '' Your own label for that case, such as Back in October, used instead of the choice above on product cards, the product page and the buy button. Empty uses the choice.
stock.backorder_text '' What a variant says while it is on backorder (tracked, nothing left, backorders allowed), unless it has its own Backorder note. Shown beside the buy button, on the option picker, in the cart, and frozen onto the order. Empty says "On backorder". On the Commerce tab, under Stock, as Backorder Wording. See Telling the shopper it is on backorder.

Cart

Key Default Effect
cart.enabled true Whether this store has a basket at all. false removes the cart and checkout URLs, the basket link in the theme, and the cart's entry in the admin. See Turning a whole subsystem off. To stop taking orders for a while, use the store status instead.
cart.purge_days 90 Days of inactivity before an unfinished cart, its items, and its adjustments are deleted. 0 keeps carts forever. Runs on every worker tick.
cart.abandoned_enabled false Sends a one-time reminder email to customers who left something in their cart. Requires the Email plugin.
cart.abandoned_hours 24 Hours a cart must sit untouched before it earns its reminder. Each cart is mailed once, ever.
cart.estimate true Puts an Estimate shipping and tax box on the cart. The shopper names a country, plus a state or postcode where the zones need one, and the cart prices postage and tax through the same engines checkout runs. Nothing is stored as an address and nothing is charged.
cart.quantity_min_default 1 The smallest quantity of any one product a customer may buy, for products that set no min_qty of their own.
cart.quantity_max_default 999 The largest quantity of any one product one order may hold, summed across every line, for products that set no max_qty of their own. A product's own min_qty, max_qty and sold_individually win over both, and the category rules sit between the two. There is no store default for qty_step. See Products and catalog.
cart.buy_now false Gives every product that follows the store a Buy now button that adds the product and goes straight to checkout instead of back to the cart. Anything already in the cart stays there and is on the checkout page too. A product's own Buy button setting wins either way. On Settings → Commerce. See Buy now.
cart.bundle_suggestions false Offers the bundle when a basket holds every part of one and the bundle works out cheaper for the shopper, on the cart and at checkout, with a button to keep them separate. A bundle is a variant with a Bundle of list. See Bundle suggestions.
cart.cross_sells true Shows Goes well with your order under the cart: the cross-sells of the products in the basket, less what is already in it, up to four. A product that needs nothing chosen gets an Add button and the rest get View. Nothing shows until a product in the basket has cross-sells. See Cross-sells in the cart.

A cart qualifies for a reminder only if it has an email address on it. In practice that means the customer submitted the checkout form and then did not pay.

Order rules

Four keys say what the whole basket has to be, whatever it is made of. All four are off as the plugin ships.

Key Default Effect
cart.order_min_qty 0 The fewest whole items a basket may go to checkout with, counted across every line. 0 is no minimum.
cart.order_max_qty 0 The most whole items one order may hold, counted across every line. 0 is no maximum. What a pallet, a picking round or a carrier contract sets, rather than a sales cap.
cart.order_min_total '0' The smallest an order may come to, as a decimal amount in the store currency ('50.00'), the same way shipping.flat_rate is entered. 0 or blank is no minimum. On a store that sells in more than one currency it can be a map of currency to amount ({ USD: '50.00', EUR: '45.00' }), and an order in a currency the map does not list has no minimum. A single amount is the base currency's, so other currencies have none. The refusal prints in the order's currency.
cart.order_max_total '0' The largest an order may come to, as a decimal amount in the store currency ('2500.00'). 0 or blank is no maximum. A map works as it does for the minimum, and an order in a currency it does not list has no maximum.

The two value rules measure the merchandise subtotal after discounts, before shipping and tax. A shopper cannot reach a minimum by paying for postage, and a store that adds tax at the last step does not enforce a £50 minimum at £41.67.

A basket outside any of the four prints the reason at the top of the cart page, and the checkout button stays where it was. A shopper who walks past it is refused at checkout with the same sentence, and POST {route}/api/checkout refuses with it under code: "validation".

Set Exclude from order rules on a product and its lines count for nothing in any of the four, neither their units nor their money. Use it for gift cards, donations, deposits and shipping protection. A basket holding nothing but excluded lines is refused nothing at all. See Order rules.

Emails

Every email renders from templates/emails/*.twig and can be overridden in your theme like any other Grav template. The emails.* keys are on the Notifications tab of the plugin settings, under Customer Emails. See Emails.

Key Default Effect
emails.store_copy_to '' BCC address for every order confirmation. Empty sends no copy.
emails.abandoned_subject '' Subject line for the abandoned-cart email. Empty uses the translated default, "You left something in your cart".
emails.abandoned_reminder_subject '' Subject line for the one follow-up reminder cart recovery sends. Empty uses the translated default. See Cart recovery.
emails.hold_for_panels_seconds 300 How long an order email may wait when an add-on's panel says what it describes is not ready yet, such as a license key still being issued. The send retries every five seconds. Past this it goes out regardless and logs one line. 0 disables the wait.
email_log.retain_days 180 How long the log of every message the store sent a customer is kept. 0 keeps rows forever. The log carries recipients and subject lines, so this is a privacy control too. See Cart recovery.

Email branding

Four fields repaint every email without editing a template. Set them under Notifications → Customer Emails, or in the YAML.

Key Default Effect
emails.brand.color '' Hex colour for links and accents inside the email card. Empty uses KahunaCart's lagoon #1a6f83.
emails.brand.button_color '' Hex colour for the call-to-action buttons. Empty uses board #ca5415.
emails.brand.logo '' Shown at the top of every email. A path on this site such as /user/images/logo.png becomes an absolute URL so a mail client can load it; an absolute URL is used as given. Empty prints your site title instead.
emails.brand.logo_width 140 Width in pixels the logo is drawn at, clamped to 40–320. Supply an image at twice this width so it stays sharp on a high-density screen.
emails.brand.show_name true Prints the store name under the logo at the top of every email. Turn it off when the logo already carries the name.
YAML
emails:
  brand:
    color: '#2f6f4f'
    button_color: '#2f6f4f'
    logo: '/user/images/logo.png'
    logo_width: 160

Nothing here is validated beyond trimming whitespace, and changes take effect after a cache clear. For anything past a repaint, override the template. See Emails.

Checkout

Key Default Effect
checkout.require_terms false Shows a terms checkbox on the checkout form and refuses the submit without it.
checkout.default_provider '' Slug of the payment method pre-selected at checkout. Empty selects the first one offered, and a slug no enabled provider answers to is ignored.
checkout.default_country '' ISO 3166-1 alpha-2 code the country select starts on, such as US. Empty means the customer chooses.
checkout.fields_enabled true Apply the merchant's and add-ons' checkout field declarations. Off draws the shipped fields and ignores every declaration. See Checkout fields.
checkout.locale_enabled true Apply each country's postcode grammar, phone grammar and required-field set at checkout. Off keeps the address layouts and drops the validation. See Countries and addresses.
checkout.locale_fallback_format '' Address layout for a country with no row of its own and no shipped layout, one line per line. Empty uses the shipped default. address.default_format names the same string and wins when both are set.
address.default_format '' Address layout for a country with no shipped layout. Empty means {name}, {company}, {address1}, {address2}, {city}, {region}, {postcode}, {country}, one per line. See Countries and addresses.
address.formats {} Per-country layout overrides, keyed by country code, such as DE: "{name}\n{address1}\n{postcode} {city}\n{country}". YAML-only. See Countries and addresses.

Deposits

Taking part of the money up front and the rest later. See Deposits.

Key Default Effect
checkout.deposits.enabled false Whether an order may be part paid. Switching it off removes the Part paid payment status too.
checkout.deposits.default_percent '' The percentage due up front on an order whose products declare no deposit of their own. A decimal string, like 30 or 12.5. Empty means no deposit unless a product asks for one. A product's own fixed deposit_minor wins over any percentage, and the largest percentage on the order wins over this.

Address validation

Optional delivery-address checking through a third-party geocoder, billed to your own account with that provider. It stays off until you set both a provider and a key. See Address validation.

Key Default Effect
checkout.address_validation.provider none none, mapbox, or google.
checkout.address_validation.api_key '' Your own key. Server-side only, never sent to the browser.
checkout.address_validation.mode suggest suggest offers corrections at checkout. off checks and stamps the order silently.

Note

Address validation never blocks a sale. A service that is down, slow, or out of quota is logged and waved through.

Express pay

The wallet-button region on the cart and checkout pages, where Apple Pay, Google Pay, Link or PayPal Express draw their buttons. KahunaCart ships the region and the button style; the button itself belongs to the provider, and no card data ever reaches KahunaCart. Nothing appears until a provider that draws one is installed. See Provider contract.

Key Default Effect
express.enabled true Draw the region at all. Off removes it from both pages and never asks a provider for a button.
express.height 48 Button height in pixels, 30 to 64. Every provider's button is drawn to this, so two buttons sit in one row at one height.
express.radius 6 Corner radius in pixels, 0 to 32.
express.theme dark dark, light or outline, the wallet SDKs' own theme names.
express.min_width 200 Narrowest a button may be drawn, in pixels, 120 to 600.

A value outside its range is clamped, not refused. The wrapper carries all four as --kahunacart-express-* custom properties for the theme. See Theming.

Order notices

These settings control notices about order changes, such as a discount code that expired between add-to-cart and checkout, an item that can no longer be purchased, or a price change. See Storefront for what a customer sees, and Orders and the admin for the merchant's half.

Key Default Effect
notices.customer_enabled true Record customer-facing notices and show them on the receipt, in the confirmation email and at checkout. Off keeps the flash message only and writes no rows. The admin notice queue is unaffected either way.
notices.dismiss_persist true A notice a customer waves away stays hidden for the rest of their browser session. The row is never deleted and a new session shows it again. Off means a notice stays put until the reason for it goes away.

Customer accounts

The account area needs the Login plugin, which owns the sign-in form, forgot password, magic links, two-factor, and remember-me. KahunaCart never authenticates anybody itself and never writes to a user account YAML. account.db_identity decides only where a customer's credentials are kept. See Customer accounts.

Key Default Effect
account.db_identity true Storefront customers sign in against KahunaCart's own database instead of user/accounts/*.yaml. Admin accounts are unaffected and always win a lookup. Read Store accounts before changing it.
account.checkout_registration true Offers guests an opt-in account at checkout. The account is opened at order completion and the customer gets an emailed link to set a password. Requires account.db_identity.
account.invite_expiry_days 7 How long a set-password link stays clickable. After that the ordinary forgot-password form works for them.
account.profile_slug profile The sub-page under {route}/account that carries the password and two-factor screen. A value clashing with orders, downloads, addresses, wishlist, or a tab another plugin registered is ignored.
account.order_page_offer true Offers guests a "create an account" panel on their order page and a line in the order emails, for anyone who did not take the checkout opt-in. Requires account.db_identity.
account.gravatar false Falls back to gravatar.com when a customer has no picture of their own. Off by default because it sends a hash of their email address to a third party.
account.claim_by_email false When someone logs in with no order history, matches them to a guest customer record with the same email address and hands them that history. A record another account already holds, or one with a store sign-in of its own, is never reassigned.

Warning

account.claim_by_email is off by default, because Grav lets a user change the email on their profile without verifying it. Turn it on only if both registration and profile changes verify email addresses. A store that relied on Grav accounts picking up their guest history needs to turn it on after updating. See the trade-off.

A one-time link emailed to a customer who would rather not remember a password. All three keys need account.db_identity. See Customer accounts.

Key Default Effect
customers.magic_link_enabled true Offer sign-in links at {route}/signin: a customer types their address and gets a link that signs them in. Off answers that URL with a 404.
customers.magic_link_minutes 20 How long a link works for, from 1 to 1440. Single-use either way, and setting a password invalidates every outstanding one.
customers.magic_link_rate_per_hour 5 How many links one address, and one visitor, may ask for in an hour. 0 turns the counter off, which is right only behind something that already rate-limits.

Clicking a link is counted separately, by security.rate_limits.magic_link_consume.

Customer groups

A group is a named set of customers a pricing rule, a coupon condition, a shipping rule or a member-only product can point at. Groups themselves are rows, created in the admin or over the API. See Customer groups.

One key decides how a Grav account's own user groups reach its customer record.

Key Default Effect
customers.group_map [] Which customer group a Grav account's user groups put its customer into at sign-in. A list of {grav_group, customer_group} entries, where grav_group is a Grav user group name and customer_group a KahunaCart group code. First match wins, so order the list from specific to general. A sign-in never takes a group away, but it does move somebody the map names. Leave it empty on a store where groups are picked by hand.
YAML
customers:
  group_map:
    - grav_group: members
      customer_group: members
    - grav_group: staff
      customer_group: staff

A code this store does not have is logged and ignored rather than created. See Map a Grav user group to a customer group.

Articles with a teaser

A page marked paywall: <group> is open to everyone, and a visitor who doesn't hold the group sees the part above the === line and a subscribe panel. These are the store-wide settings. Each page can override the teaser length and the subscribe link. In Admin Next they're under Settings → Accounts → Articles with a teaser. See Customer accounts → Articles with a teaser.

Key Default Effect
paywall.teaser_paragraphs 2 How many blocks (a paragraph, a list, a quote) a visitor sees when the page has no === line. 0 shows none. An article with no more blocks than this still keeps its last one back.
paywall.subscribe_url '' Where the panel's Subscribe button goes when no plan sells the page's group. Plans from Subscriptions list themselves, so this is the fallback. Empty shows only the Sign in link.
paywall.meter.free 0 How many paywalled articles a visitor who can't read them may open in full each period. 0 turns the meter off, and then nothing is written and no cookie is set. The most is 50, because a guest's reads live in a cookie and a browser drops one over 4 KB.
paywall.meter.period month month or week, by the calendar in the store's timezone (reports.timezone, else Grav's system.timezone).
paywall.meter.who everyone everyone counts guests in a signed kc_meter cookie and customers in the database. accounts gives guests no free reads.
paywall.meter.register_url '' Where Create a free account goes when who is accounts. Empty uses the Login plugin's registration page when its registration is switched on.

Wishlists

Key Default Effect
wishlists.enabled true Whether the storefront wishlist panels, buttons and routes exist at all. Off removes them entirely rather than hiding them.
wishlists.anonymous_days 90 How long a list belonging to a browser cookie rather than to an account is kept after it was last touched. The worker sweeps the rest. 0 keeps them forever.

An anonymous list is addressed by a cookie named kahunacart_wishlist (HttpOnly, SameSite=Lax, one year) and is merged into the account when its owner signs in. See Wishlists.

Reviews and ratings

Key Default Effect
reviews.enabled true Whether reviews are shown on product pages and new ones accepted.
reviews.moderated true Every review waits for a merchant. Off publishes a verified purchaser's review immediately and still holds a stranger's.
reviews.allow_anonymous false Whether somebody without an account may write one at all.
reviews.request_after_days 0 Email a customer asking for a review this many days after their order completes. 0 is never. One request per order, ever.

The verified-purchase badge has no config key and no API field. It is computed from the customer's own paid orders, and recomputed when a review is approved. See Reviews and ratings.

Cart recovery

A signed restore link in the abandoned-cart email, one follow-up reminder, and a share link a merchant sends for a phone or social sale. recovery accepts false in place of its block. See Cart recovery.

Key Default Effect
recovery.enabled false Put a signed restore link in the abandoned-cart email and answer the restore route.
recovery.reminder_enabled false Send one reminder to a cart that was mailed and did not come back.
recovery.reminder_hours 48 Hours after the first message before the reminder is due.
recovery.link_days 7 How long a recovery link works for. It may be opened more than once.
share.link_hours 24 How long a share link works for. It is single-use.
share.challenge_required true An anonymous load of a share link has to name the email address on the cart before it opens. See Share a cart with a customer.

The retention schedule

Four windows, all off by default, so upgrading a live store deletes nothing by surprise. Each is a number plus a unit, and the unit is one of days, weeks, months or years. A month is 30 days and a year is 365. A window of 0, a negative number, a non-number or an unrecognised unit all read as off.

Key Default Effect
retention.trash_pending_after 0 Archive placed orders whose payment never arrived, this far back.
retention.trash_pending_unit days The unit the window above is counted in.
retention.trash_failed_after 0 Archive orders whose payment failed, this far back.
retention.trash_failed_unit days The unit the window above is counted in.
retention.anonymise_completed_after 0 Anonymise finished sales this far back. The customer's details go; every amount stays.
retention.anonymise_completed_unit months The unit the window above is counted in.
retention.delete_inactive_accounts_after 0 Erase customer records after this period of inactivity, subject to the same restrictions as manual erasure.
retention.delete_inactive_accounts_unit years The unit the window above is counted in.
retention.chunk 200 Rows one rule may touch in one run, so a first sweep cannot monopolise the worker. --limit on the CLI overrides it.

Trashing means archiving; the two trash rules delete nothing. Completed orders are anonymised, not deleted, so revenue, tax and product reports return the same numbers afterwards. Run bin/plugin kahunacart retention:run --dry-run before switching any window on. See The retention schedule.

The payment reference

Key Default Effect
orders.payment_reference_enabled true Give every completing order a short reference a customer can quote on a bank transfer and you can match against a statement. It avoids look-alike characters, so nobody confuses a zero for an O. Off leaves the field empty and every document prints the order number instead. See Orders and the admin.

Invoices

Invoices print at {route}/order/{hash}/invoice and download as a PDF from {route}/order/{hash}/invoice.pdf. Both render the same document. The first five keys are on the Accounts tab, under Invoices.

Key Default Effect
invoice.store_name '' Printed at the top of every invoice. Empty falls back to the site title.
invoice.store_address '' Your business address, one line per line.
invoice.tax_id '' VAT or tax registration number. Omitted from the invoice when empty.
invoice.footer '' Markdown printed at the foot of every invoice.
invoice.reverse_charge_text '' The line printed under the totals of an order entered with "Don't charge tax", on the receipt, the invoice and credit notes. Empty prints "VAT reverse charge: the customer accounts for the VAT." in the reader's language. Reverse Charge Wording in the admin. See Tax → Orders entered with "Don't charge tax".
invoice.paper a4 Page size of the invoice PDF and the packing slip: a4, letter, legal or a5. Anything else reads as a4. YAML-only.

Invoice numbering

An invoice number comes from its own counter, separate from the order number, and is allocated when an order completes. In much of Europe it has to be sequential, gapless and carry the year. These three keys are on the Accounts tab, under Invoice Numbering.

Key Default Effect
invoice.sequence_start 1 The number given to the first invoice. Later invoices count up from it. Raising it once invoices exist moves the floor up and never renumbers what is already issued.
invoice.format '{number}' How the number is printed. See the placeholders below.
invoice.reset_yearly false Starts again at invoice.sequence_start every January, in UTC, which several tax authorities require.

The format takes three placeholders and leaves everything else exactly as written.

Placeholder Prints
{number} The counter, as it is.
{number:6} The counter zero-padded to six digits. Any width from 1 to 99; padding never truncates.
{year} The four-digit UTC year the invoice was allocated in.

INV-{year}-{number:5} prints INV-2026-00042.

Important

Put {year} in the format whenever invoice.reset_yearly is on, or the second invoice of next year prints the same number as the second invoice of this one.

Note

Orders placed before invoice numbers existed have none and never will. Their invoice prints the order number in that slot. Nothing is backfilled.

Store credit and gift cards

One append-only ledger per customer, with the balance summed from it and never stored. Gift cards are built on that ledger. See Store credit, gift cards and credit notes.

Key Default Effect
credit.enabled true Offer store credit as a payment method at checkout. It is only shown to a signed-in customer whose balance covers the whole order. Turning it off leaves the ledger and gift cards working, and stops credit being spent at checkout.
credit.label 'Store credit' What the payment method is called on the checkout page.
gift_cards.enabled true Publish the gift-card product type and the redemption box at checkout. Turning it off hides both and leaves every card already issued redeemable.
gift_cards.code_pattern '####-####-####-####' What a generated code looks like. # is a random character and everything else is printed as written. Characters come from the same alphabet coupon codes use, which has no I, L, O or U.
gift_cards.expiry_days 0 How many days a card stays redeemable after it is issued. 0 never expires. Several jurisdictions regulate gift-card expiry and some forbid it, so check what applies to you before setting a number.

Credit notes

A credit note is issued automatically whenever a refund is confirmed, and is numbered from its own book, not the invoice's.

Key Default Effect
credit_note.sequence_start 1 The number given to the first credit note. Raising it once notes exist moves the floor up; it never renumbers what is already issued.
credit_note.format 'CN-{number}' How the number is printed. The same three placeholders the invoice format takes: {number}, {number:6} to zero-pad, {year} for the UTC year.
credit_note.reset_yearly false Start again at sequence_start every January (UTC). Put {year} in the format as well, or numbers repeat.

Offline payments

All three keys are on the plugin settings page, under Commerce → Offline Payment. A store using payment methods merchandises the offline gateway there instead, and a method's own label and instructions win where there are any.

Key Default Effect
offline.enabled true Registers the built-in offline provider as a payment option.
offline.label 'Bank Transfer' What the customer sees at checkout.
offline.instructions '' Markdown shown on the confirmation page for offline orders, such as where to send the money. Applies to any order with no payment method on it.

Digital delivery

See Digital products for the full picture, including the nginx and Apache configuration the non-PHP delivery methods need.

Key Default Effect
downloads.grant_on paid When download grants are created. paid waits for funds to be captured. completed grants as soon as the order is placed, which is what offline methods need.
downloads.default_limit ~ (unlimited) Downloads allowed per grant when the file itself sets no limit.
downloads.default_expiry_days ~ (never) Grant lifetime in days when the file itself sets none.
downloads.method php php, xaccel, or xsendfile. Anything unrecognized falls back to php.
downloads.xaccel_prefix /kahunacart-files/ Internal nginx location that maps to the files directory. Used only by the xaccel method.
downloads.path '' Where downloadable files live. Empty means user-data://kahunacart/files. Absolute paths and Grav stream URIs both work. An absolute path outside the document root is the safest setup.
downloads.allowed_roots [] Extra directories a purchased file may be served from. downloads.path is always allowed. Every product file is checked against this list on upload and again when its bytes go out, through realpath(). Add an entry only when files live in two places, which in practice means a migration. See Hardening.
downloads.max_upload_mb 512 Largest single file the admin upload endpoint accepts. PHP's upload_max_filesize and post_max_size still apply and are usually the lower limit.

default_limit and default_expiry_days read as unset when empty, non-numeric, or zero or below. Clearing the admin field means unlimited or never, not zero.

Run bin/plugin kahunacart downloads:check to list every product file that can no longer be delivered.

The receipt, invoice, invoice PDF and pay pages are each reached by a signed link that expires. A customer whose link has run out gets a page offering to email a fresh one to the address on the order. All three keys are on the Security tab, under Order Links. See Hardening.

Key Default Effect
security.order_link_days 30 How long a signed link to an order receipt keeps working.
security.invoice_link_days 30 How long a signed link to an invoice or its PDF keeps working. Often set longer than the receipt, because an invoice gets forwarded to whoever pays the bills.
orders.pay_link_days 7 How long a pay link stays payable once one is issued. Issuing another from the order screen moves the deadline forward.
orders.quote_expiry_days 30 How long a quote is good for when you do not name a date yourself. A quote's deadline is about the offer and does not move when a pay link is reissued. See Quotes.

The key the links are signed with is not a config setting. KahunaCart creates it from 32 random bytes and stores it under security.secret in kahunacart_settings. An empty key or one shorter than 32 characters is repaired under a lock before signing. If it cannot be stored, signing stops. Configuration export omits it and import never replaces it. Deleting that row rotates it and invalidates links the store has already emailed.

Rate limits

Every route a visitor can hammer is counted per client address in a named bucket. Each bucket is two keys, a limit and a window in seconds, and both are settable on their own. A limit of 0 turns that bucket off. YAML-only. See Hardening.

Keys Default Covers
security.rate_limits.cart_write.limit, security.rate_limits.cart_write.window 60 in 60 Add, update, remove, apply a coupon.
security.rate_limits.checkout.limit, security.rate_limits.checkout.window 10 in 60 The checkout submit.
security.rate_limits.order_lookup.limit, security.rate_limits.order_lookup.window 30 in 60 Receipt and pay pages, by hash. Counted per order as well as per address.
security.rate_limits.invoice.limit, security.rate_limits.invoice.window 20 in 60 The invoice and its PDF. Counted per order as well as per address.
security.rate_limits.download.limit, security.rate_limits.download.window 30 in 60 Download links.
security.rate_limits.link_request.limit, security.rate_limits.link_request.window 3 in 3600 "Email me a new link". Counted per order as well as per address.
security.rate_limits.fragment.limit, security.rate_limits.fragment.window 120 in 60 Estimate, search and filter fragments.
security.rate_limits.account_offer.limit, security.rate_limits.account_offer.window 3 in 3600 The post-checkout account offer.
security.rate_limits.wishlist_write.limit, security.rate_limits.wishlist_write.window 60 in 60 Every wishlist write: add, remove, rename, share, move to cart.
security.rate_limits.review_submit.limit, security.rate_limits.review_submit.window 5 in 3600 Writing a product review.
security.rate_limits.magic_link_consume.limit, security.rate_limits.magic_link_consume.window 20 in 3600 Clicking a sign-in link. Asking for one is capped by customers.magic_link_rate_per_hour instead.
security.rate_limits.cart_restore.limit, security.rate_limits.cart_restore.window 10 in 3600 Opening a cart-recovery link and answering its email challenge. Counted per link as well as per address. See Cart recovery.
security.rate_limits.agent_read.limit, security.rate_limits.agent_read.window 120 in 60 Agentic commerce reads: the product feeds, the storefront MCP catalog and policy tools, and UCP GETs and catalog calls.
security.rate_limits.agent_write.limit, security.rate_limits.agent_write.window 60 in 60 Agent carts and checkouts: create, update and cancel, over MCP and UCP.
security.rate_limits.agent_complete.limit, security.rate_limits.agent_complete.window 10 in 60 Completing an agent checkout.
security.rate_limits.agent_profile_fetch.limit, security.rate_limits.agent_profile_fetch.window 30 in 60 The store's own fetches of platform UCP profiles, counted store-wide rather than per address. A spent budget answers 503.

The agent buckets are counted per address, except that a UCP request that has authenticated (a valid API key or a verified signature) is counted per platform and session, because platforms call from a few shared addresses. See Hardening → Agent endpoints.

YAML
security:
  rate_limits:
    checkout: { limit: 5, window: 60 }
    download: { limit: 0, window: 60 }

A limited request answers 429 with a Retry-After header and is logged at info level. Counts live in kahunacart_rate_limits in the store's own database, so every worker and every node counts against the same rows.

Site address

Key Default Effect
security.site_urls [] The addresses this store is served on, such as https://shop.example.com. Links in emails use the one the customer was on, or the first one. A request on any other host is never trusted to name the site. An entry with no scheme is read as https://. On the Security tab, under Site Addresses.

system.custom_base_url, when set, is used instead of all of them. With both empty, sign-in, set-password and cart links are not sent. While both are empty, security.site_urls is filled in with the address an administrator is using the first time they open the admin dashboard or KahunaCart's admin page, or save KahunaCart's settings, as long as that is an HTTPS address and not a development one. See Hardening → Site address for emailed links.

Trusted proxies

Key Default Effect
security.trusted_proxies [] IP addresses and CIDR ranges of the proxies in front of this store. X-Forwarded-For is read only when the connection itself came from one of them. On the Security tab, under Trusted Proxies.

Empty means the header is ignored and REMOTE_ADDR is the client, which is correct for a store nothing sits in front of.

Warning

Behind Cloudflare or a load balancer, leaving this empty puts every visitor in one rate-limit bucket. Filling it in with a range you do not control lets anyone forge their own address. See Hardening.

Demo store

Key Default Effect
demo.readonly false Refuses every request to the KahunaCart admin API that is not a GET, HEAD or OPTIONS, for everyone signed in, with a 403 and a translated message. The storefront, checkout, webhooks, the job worker, the CLI and migrations are untouched.
demo.protected_customers [] Email addresses whose credentials and identity are frozen, for the shared storefront logins a demo publishes on its sign-in page. Those accounts cannot change password, email, name or two-factor setting and cannot be recovered by email. Addresses, orders, downloads, the cart and the checkout still work.

Both are on the Database tab of the plugin settings, under Demo Store. See Demo store for what it locks, what it leaves open, and how to reset a public demo on a timer.

Storefront JSON API

Key Default Effect
storefront_api.enabled true Answers the JSON endpoints under the storefront route, {route}/api/cart and its line items, coupons, shipping quotes, payment methods, checkout and countries, for a JavaScript or headless storefront. The HTML pages, forms and fragments work either way. false answers every {route}/api URL with a 404.
storefront.purchase_event true Puts a description of the sale on the confirmation page — order number, money, coupons and lines, and nothing that identifies a customer — and fires kahunacart:purchase on document for your own GA4, Google Tag Manager, Plausible or Meta snippet to pick up. Nothing is sent anywhere until you write that snippet, so leaving it on costs a couple of hundred bytes on one page and tells nobody anything. false draws no block and loads no script. See Storefront → Tell your analytics about a sale.
storefront.whole_amounts false Prints prices without their cents when there are none: $55 instead of $55.00. The Whole Amounts switch on the General tab. A price with cents keeps them and is never rounded. Prices shown together are printed alike, so $55.00 $49.50 never turns into $55 $49.50, and the amounts of one cart or order summary are printed alike too. A currency with no minor unit, such as JPY, is unchanged. Storefront pages only: emails, invoices, credit notes, reports, feeds, structured data and the API always print full amounts. See Theming → Whole amounts and the word for the cart.
storefront.cart_word cart What the storefront calls the cart: cart, bag or basket. The Name for the Cart select on the General tab. Every shopper-facing string that names the cart follows: the buy button, the cart page's title, the empty cart, the currency and coupon messages, the wishlist's Move buttons, the restore page and the back links from payment. The admin and emails keep saying cart. A translation without bag or basket strings keeps its own cart wording. See Theming → Whole amounts and the word for the cart.

Every endpoint, status code and error code is in Storefront JSON API.

Agentic commerce

The settings behind structured data, the product feeds, the storefront MCP endpoint and UCP, all on the Agentic Commerce tab. Everything that answers a request is off as shipped. See Agentic commerce for what each part does and the setup checklist.

The master switch

Key Default Effect
agentic.enabled false Turns agentic commerce on. While it is off, and on a new install or an upgrade where the key is missing, the store behaves exactly as it did before: /.well-known/ucp, {base}/feeds/*, {base}/mcp and {base}/ucp/* answer 404, no feed build, order webhook or signing key work runs, the product JSON-LD leaves out the agentic additions, the product editor hides Agent checkout, and the doctor reports nothing about it. feeds.enabled, storefront_mcp.enabled, ucp.enabled and the four seo.* additions only take effect while it is on. The product fields (brand, condition, final sale, Google category, GTIN, MPN) work either way.

Seller

Key Default Effect
seller.name '' Who sells, in structured data, the feeds and UCP. Empty uses invoice.store_name, then the site title.
seller.default_brand '' The brand of a product that sets none. Empty publishes no brand, except in the OpenAI feed, which requires one and falls back to the seller name.
seller.logo '' The logo on the shop front page's OnlineStore markup. A site-relative or absolute URL.
seller.support_email '' The support contact on that markup. Left out when it is not an email address.
seller.support_phone '' The support phone, as written.

Store policies

Every URL may be site-relative (/terms) and is published absolute. A link that is not http or https is dropped.

Key Default Effect
policies.terms_url '' Terms of service. UCP will not start without it.
policies.privacy_url '' Privacy policy. UCP will not start without it.
policies.refund_url '' The returns page. No return policy is published anywhere until this is set, whatever the returns keys say.
policies.shipping_url '' The shipping page.
policies.faq_url '' The FAQ page, which the search_shop_policies_and_faqs tool reads.
policies.returns.accepted true Whether returns are taken at all. Off publishes "returns not permitted".
policies.returns.window_days 30 Days a buyer has to return an item.
policies.returns.countries [] ISO alpha-2 codes the policy covers. Empty is checkout.default_country.
policies.returns.method by_mail by_mail, in_store or by_mail_or_in_store.
policies.returns.fees free free, customer_pays or fixed.
policies.returns.fee '' The fixed return fee, used when fees is fixed: a decimal string in the base currency, or on a store that sells in more than one currency a map of currency to decimal string ({USD: '4.50', EUR: '4.00'}). A fixed policy with no usable amount, including one with no entry for the currency it is read in, is published as customer_pays.
policies.shipping_summary [] One row per country, {country, rate, free_over, handling_days, transit_days}, with amounts as decimal strings and days as "0-1" or "3". Structured data and feeds need a price with no address to quote it for, so this is written by hand rather than read from the shipping zones. A row without a country and a rate is ignored, and so is a second row for the same country. Empty publishes no shipping details. rate and free_over are each a decimal string in the base currency or a map of currency to decimal string, like the return fee; a row with no rate in the currency being published is left out of that currency's feed and structured data, and a free_over with no entry in it is no threshold there.

Feeds, the MCP endpoint and UCP

Key Default Effect
feeds.enabled false Serves the product feeds under {base}/feeds.
feeds.google true The Google primary and supplemental feeds, google.tsv and google-checkout.tsv.
feeds.openai true The OpenAI feed, openai.jsonl.gz.
feeds.access_key '' When set, a feed answers only with ?key= equal to it.
feeds.item_id sku sku uses a variant's SKU as its feed id when it is safe and unique, else kc-v{id}. id always uses kc-v{id}.
feeds.include_out_of_stock true Lists out-of-stock variants as out_of_stock rather than leaving them out.
feeds.backorder_days 14 The availability_date on a backorder row, as days from today.
feeds.google_category '' The Google product category for products that set none.
feeds.max_age 21600 Seconds before the worker rebuilds a feed file. At least 300.
feeds.inline_limit 2000 A missing file is built inside the request when the catalog has at most this many variants. 0 never builds in the request.
storefront_mcp.enabled false Serves the public MCP endpoint at {base}/mcp.
storefront_mcp.allow_anonymous true Lets a client that sends no UCP profile browse, build carts and hand the buyer to checkout. Off, only UCP platforms are served.
storefront_mcp.allowed_origins [] Browser origins, as https://app.example, allowed to call the endpoint from a web page. The site's own origin is always allowed.
ucp.enabled false Serves /.well-known/ucp and {base}/ucp.
ucp.session_ttl 21600 Seconds an agent checkout or cart stays open. At least 300.
ucp.accept_unknown_platforms true Any platform whose profile passes the fetch guards may transact. Off, only the hosts in ucp.platforms are served.
ucp.platforms [] Profile hosts always accepted, such as google.com. A host matches itself and its subdomains.
ucp.order_webhooks true Sends signed order webhooks to the platform and serves GET {base}/ucp/orders/{id}.
ucp.google.api_key '' The X-API-Key configured in Merchant Center. When set, a request from a google.com profile must carry it.
ucp.google.order_webhook_url '' The order webhook URL Google issues at onboarding.
ucp.google_pay.merchant_id '' Your Google Pay and Wallet Console merchant id. Required in PRODUCTION.
ucp.google_pay.environment TEST TEST or PRODUCTION.
ucp.google_pay.card_networks [AMEX, DISCOVER, JCB, MASTERCARD, VISA] The networks the Google Pay sheet offers.
ucp.google_pay.auth_methods [PAN_ONLY, CRYPTOGRAM_3DS] The authentication methods the sheet accepts.

ucp.google.api_key and ucp.google.order_webhook_url are stored encrypted when saved from the admin, like a provider's secret. See UCP checkout for what each key changes.

Merchant notifications

Outbound notifications to Slack, Discord, or an endpoint of your own. No channel is configured by default. See Notifications for payload details and the signing contract.

Key Default Effect
notifications.enabled true Master switch. false silences every channel without deleting them.
notifications.low_stock_threshold 5 Stock at or below this earns a stock.low event. 0 reports only when an item has run out.
notifications.timeout 5 Seconds allowed per channel per POST. Capped at 15.
notifications.channels [] The list of channels.

Each entry in notifications.channels takes these keys.

Key Effect
label Name shown in the admin, such as Sales.
type slack, discord, or webhook.
url The endpoint you paste from the service.
enabled Whether this channel sends.
events List of event names, such as [order.completed, payment.received], or all.
secret Signs the request body. Webhook channels only.

Outbound webhooks

Subscriptions an integrator registers to be told when something happens here. Distinct from the merchant notification channels above and from the inbound webhooks a payment provider sends you. See Outbound webhooks.

Key Default Effect
webhooks.max_attempts 5 How many times one occurrence is tried, with backoff, before its delivery job is failed. Each attempt is a delivery row.
webhooks.disable_after 5 Consecutive failed deliveries that switch a subscription off. It stays off until a person turns it back on.
webhooks.retention_days 30 How long delivery log rows are kept. The worker's tick sweeps older ones. 0 keeps them forever.
webhooks.timeout 5 Seconds a receiver has to answer with a 2xx, from 1 to 15.

The job queue

Cron's floor is a minute, so a confirmation email queued a second after the tick waits fifty-nine seconds for nothing but the clock. These keys govern the two ways of closing that gap and the deadline every job runs under. See CLI.

Key Default Effect
jobs.inline_drain true Whether a request that queued jobs runs those jobs itself once the response has gone to the browser, instead of leaving them for the next scheduler tick. It only ever runs the jobs that request queued, and cron still picks up anything it did not reach. Never runs in the admin or under the CLI. Turn it off if your host struggles with work after the response.
jobs.inline_budget_seconds 3 How long a request may spend on its own jobs after the response, capped at 30. On PHP-FPM the customer already has the page and this costs them nothing. On mod_php the browser is still waiting, so keep it small.
jobs.job_timeout_seconds 120 How long one job may run before the worker gives up on it and marks it failed, so a provider that stops answering cannot hold the queue. Only a build with the pcntl extension can stop a handler mid-call; without it the overrun is recorded once the handler returns. 0 turns the limit off.
jobs.daemon_poll_seconds 1 For stores running bin/plugin kahunacart work --daemon: how long the loop waits after finding nothing to do. Ignored by the every-minute scheduler tick.
jobs.daemon_housekeeping_seconds 60 How often the running daemon does the tidying the scheduler tick does: releasing expired stock holds, purging old carts, sweeping logs, rolling up report days. Once a minute matches the scheduler.

Note

Cron stays installed whichever of the others you use. It is the only one guaranteed to run.

Webhook log and job warnings

Key Default Effect
payments.webhook_log_days 30 Webhook delivery rows older than this are swept by the worker. 0 keeps them forever.
jobs.stale_after_minutes 15 The admin warns when the oldest waiting job is older than this. 0 disables the warning.
jobs.retention_days 30 How long finished and cancelled jobs stay in the queue table for the Jobs screen and jobs:list to show. The worker's tick sweeps older ones. 0 keeps them forever. Open jobs are never swept.

Important

Background work is queued and drained by Grav's scheduler. If the scheduler is not running, the queue piles up and customers who paid for a download never get one. See Troubleshooting.

Catalog sync

YAML-only. The keys are provider slugs, so there is no admin field for them. See Sync.

YAML
sync:
  polar:
    direction: remote
Direction Meaning
local The local catalog is master. Admin product writes queue a push job per local-master provider.
remote The provider is master. Its catalog webhooks apply locally, and pull jobs re-pull it.
off Default. Nothing runs automatically. Anything unrecognized reads as off.

Direction governs automatic work only. A manual bin/plugin kahunacart sync --push --provider=<slug> or --pull --provider=<slug> always does what you asked.

Bulk edits, imports and exports

These govern the bulk editor, the CSV importer and store bundles. See Importing for the CSV importer, Export and restore for store bundles, and Products and catalog for the bulk editor.

Key Default Effect
admin.bulk.max_rows 1000 How many products one bulk edit may change in a single run. A run selecting more is refused with a message naming the limit. The whole run happens in one transaction.
import.chunk_rows 200 How many rows one batch of a queued CSV import writes. Each batch is one transaction and one background job. A smaller number keeps the progress bar moving and leaves room in the queue for order emails; a larger one finishes a big file in fewer jobs.
import.keep_days 30 How many days an uploaded CSV is kept on disk after its run finishes. These files carry cost prices and customer addresses, so this is a privacy control too. 0 keeps them indefinitely.
export.keep_days 7 How many days a store bundle is kept in user/data/kahunacart/exports after it is written or uploaded. A bundle is the whole store, customers and all, so the worker deletes it after this. Its line in the History tab stays. 0 keeps bundles until you delete them.

Database

Key Default Effect
database.type sqlite sqlite, mysql, pgsql, or connection.
database.auto_migrate sqlite When schema updates are applied automatically. See The auto_migrate policy.
database.sqlite.path '' Empty means user-data://kahunacart/db/kahunacart.sqlite.
database.mysql.host localhost MySQL or MariaDB host.
database.mysql.port 3306 MySQL or MariaDB port.
database.mysql.dbname '' Database name.
database.mysql.username '' Database user.
database.mysql.password '' Database password.
database.pgsql.host localhost PostgreSQL host.
database.pgsql.port 5432 PostgreSQL port.
database.pgsql.dbname '' Database name.
database.pgsql.username '' Database user.
database.pgsql.password '' Database password.
database.connection '' Name of a connection defined in the Database plugin. Read only when database.type is connection.

SQLite

The zero-configuration default. KahunaCart creates the directory, writes deny-all protection files into it, chmods the database file to 0640, and opens it with journal_mode=WAL, foreign_keys=ON, busy_timeout=5000, synchronous=NORMAL, and temp_store=MEMORY.

MySQL / MariaDB

KahunaCart connects with charset=utf8mb4 and expects the server to run in strict mode. bin/plugin kahunacart status warns you when it does not, because a non-strict MySQL truncates data silently instead of refusing it.

PostgreSQL

A standard connection. There is nothing extra to configure.

Named connections

Set database.type to connection to hand connection management to the Database plugin. Put the connection name in database.connection:

YAML
database:
  type: connection
  connection: commerce

KahunaCart pulls the PDO handle from that connection and wraps it in the matching dialect. Two errors are possible:

  • database.type is "connection" but database.connection is empty — set database.connection.
  • named connections require the "database" plugin — install the Database plugin.

The auto_migrate policy

Value Behaviour
sqlite (default) Auto-migrates only when the engine is SQLite. Server databases are left alone.
auto Always auto-migrates, on every engine.
manual Never auto-migrates. bin/plugin kahunacart migrate is the only way schema changes get applied.

When auto-migration is active, the check runs on every non-admin frontend request and on every KahunaCart admin API request. It is a single "is anything pending" query, and it applies migrations only when something is.

Warning

Migrations are up-only. There are no down migrations, so recovery means restoring from backup. Applies run under a database run-lock, so two concurrent requests, or a request racing a CLI run, cannot interleave DDL.

Bulk actions and order exports

Key Default Effect
orders.bulk.max_orders 500 How many orders one bulk action may touch in a single run. Past it the run is refused with a message naming the cap, before anything is queued.
orders.export.max_rows 5000 How many rows one order export may produce. Past it the export is refused with a message suggesting a narrower date range.
orders.refund_window_days 14 How long after buying a customer may ask you for a refund, counted from when the order was placed. Asking is all it is — the money decision stays with you, on the order screen. 0 takes no requests through the website at all and draws nothing on the receipt. See Refund requests.
orders.contact_url '' Where a customer is sent when they cannot ask for a refund — past the window, or on an order already refunded. A page address or a mailto:. Empty means there is no Contact us link at all: nothing is guessed from your email settings, because an offer to get in touch should go where you said and nowhere else.

See Bulk actions on orders and Export orders with your own columns.

Reporting

What a report covers when it opens, what it compares against, and which day an order belongs to. See Analytics.

Key Default Effect
reports.timezone '' Where the store's days begin, for every report boundary and every comparison period. Empty falls back to Grav's system.timezone, then to UTC. Storage is UTC epoch throughout; this decides only which day an order lands on. Changing it on a store that has been reporting for a year moves orders between days.
reports.default_comparison previous_period The default comparison for a report: previous_period, previous_year or none. Pick previous_year for a seasonal store.
reports.default_days 30 How many days a report covers when it opens, counting back from today.
reports.attribution.enabled true Whether to record where an order came from: the referrer bucket, any utm_ parameters and a device bucket, written against the order.

reports.attribution.enabled ships on and is the one key here that records something about visitors. Nothing is stored about a visitor who does not order, and nothing external is contacted: no beacon, no pixel, no outbound request. The user agent is read once for a desktop, mobile, tablet or bot bucket and never stored. Set it to false to stop the capture; orders already captured keep what they have.

Dashboard alerts

The strip at the top of the store admin. See Dashboard alerts.

Key Default Effect
alerts.enabled true Whether the checks run at all. Off runs none.
alerts.min_recheck_seconds 0 A floor under every alert's own recheck interval, an add-on's included. Zero leaves each alert to its own timing. Raise it on a store whose database is slow and every check gets cheaper at once.

The extension platform

Key Default Effect
extensions.hooks.enabled true Whether compensating hooks run. Core's download-grant issuance goes through them, so false puts grants back on the direct path and takes every add-on's undo with it: an issued licence then stays issued whatever happens next. See Provider developer guide.

Settings that are not config keys

Which emails go out, which template pair each renders from, and the operator-editable formats (SKU, line titles, the order reference and description, the invoice file name and every email subject) are not YAML keys. They are store data, edited on the Notifications and Object templates screens in the admin or through their API routes, and kept in kahunacart_settings. See Emails and Object templates.

Feature gates

Core features that change how orders are stored are switched on per store. KahunaCart refuses to switch one on while an installed add-on has not declared that it understands it. The Settings → Features screen names any plugin that is blocking.

Key Default Effect
features.order_versioning false Whether a fresh store starts with staged, versioned order changes available. Your choice on the Features screen is stored in kahunacart_settings and wins over this key, so this is only the default for a store that has never been asked.

Switching a feature off is never refused.

Turning a whole subsystem off

Nine settings accept false in place of their whole block. The subsystem is then gone, not just switched off inside itself: its storefront routes stop being routes, the admin nav loses its entry, and the Twig variables are absent rather than empty.

Subsystem Storefront routes it owns Admin nav it owns
cart /cart, /checkout —
wishlists /wishlist —
reviews — Reviews
storefront_api /api —
recovery — Recovery
credit — Store credit
feeds /feeds —
storefront_mcp /mcp —
ucp /ucp, and /.well-known/ucp at the site root —

The three forms

Each of these is the same instruction, and the plugin reads all three:

YAML
# 1. Off, at its shortest.
cart: false

# 2. Off, written inside the block. Use this form when the block also
#    carries settings you want to keep for when it comes back on.
cart:
  enabled: false
  purge_days: 90

# 3. On, which is what every one of them defaults to.
cart: true

feeds, storefront_mcp and ucp are the exception to the third comment: they ship off, so a config that does not mention them has them off, and feeds: true switches the feeds on with every other key at its default. All three also need agentic.enabled on.

Use the second form in a file you edit by hand. cart: false replaces the whole block, so writing it and later writing cart: true loses purge_days along the way. enabled: false keeps everything and turns one switch.

Note

With cart: false the storefront answers 404 for /cart and /checkout, the theme's basket link has nothing to render from, and the admin's cart entry is absent. That is how you run a catalogue with no cart. To close a store for a move or a holiday, pause it with the store status instead.

Settings that disagree with each other

Some combinations are individually valid and jointly wrong. GET /kahunacart/config/contradictions reports them, and the Features screen shows them with a button that writes the fix. Every rule is published whether or not it fires.

Rule Fires when Why it matters Recommended fix
tax_inclusive_without_a_rate tax.included is on and tax.rate is zero or unset With no rate to take out of the price, every order records zero tax and the whole amount as revenue. Turn tax.included off until a rate is set
tax_inclusive_shipping_untaxed tax.included is on, there is a rate, and tax.apply_to_shipping is off In a tax-inclusive store the delivery charge is expected to include tax too. Left untaxed, the difference comes out of the margin. Turn tax.apply_to_shipping on
checkout_without_a_cart cart is off Nobody can reach checkout without a basket. Turn cart back on, or leave both off for a catalogue

The fix is merged into your own configuration file, so everything the file already holds survives. Comments in the file are not preserved; the values are.

Moving a configuration between stores

bin/plugin kahunacart config:export and config:import move a store's configuration as a JSON document, and the admin has the same two behind kahunacart.settings. See CLI.

Section Holds
plugin The plugin's own YAML configuration, which is everything on this page
settings The kahunacart_settings table: invoice counters, feature flags, property labels and the rest
tax Tax zones, rates and their rules
shipping Shipping zones, methods, rates and pickup locations
promotions Coupons, sales and price lists
report_views Saved report views and per-user column choices

The catalogue is not included; that is bin/plugin kahunacart export. Orders and customers are not included either, and nor is the store status: an import keeps the target store's own whatever the document says, so moving configuration from staging to live neither pauses nor opens the live store. To move the whole store, settings, catalogue, customers and orders together, use a store bundle. See Export and restore.

Amounts in other currencies. On a store that sells in more than one currency, the shipping section also carries what its methods and collection points charge in each currency, and the promotions section carries what its coupons and sale rules are worth in each, including fixed quantity-tier amounts and named money arguments. They travel as a currency_amounts list in the section, one row per amount (the kind of thing it belongs to, its id, the field, the currency and the amount in minor units), and only when the store has some, so a store that sells in one currency exports exactly what it always did. The prices of products are not part of this: they move with the catalogue export.

An import clears the section’s old foreign amounts even when all extra currencies are disabled. An imported id cannot inherit an old amount when a currency is enabled again.

Each imported section replaces its foreign amounts with the document’s amounts. A document with none clears them. The dry run says how many amounts would be replaced. If the document names a currency the target store does not sell in, the whole import is refused. Enable that currency and run the import again.

Two rows are never exported and never deleted by an import: reports.stats_through and reports.stats_schema. They describe this store's own rollup and would mislead another store. See Reports and the rollup.