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:
bin/grav clear-cache
To drop only the route table, delete the directory:
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.
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 carriesnoindex(a robots meta tag and anX-Robots-Tagheader), 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
503withRetry-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 astore_closederror. 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 checkwarns 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 theirprivate, 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.
Related products, cross-sells and up-sells
| 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. |
Storefront search
| 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. |
Semantic search
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. |
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.
Sign-in links
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. |
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.
Order links
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.
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.
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:
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— setdatabase.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:
# 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.
Related
- Getting started — install, first product, first sale.
- The catalog index — what the index holds, when it is joined, and how to rebuild it.
- Storefront — the search ladder and what each rung changes for a shopper.
- Price lists and quantity tiers — what
pricing.enabledgoverns. - Importing — the CSV importer and its saved mappings.
- Tax — tax zones and rates.
- Customers, wishlists, reviews and retention — what the wishlist, review and retention keys govern.
- Store credit, gift cards and credit notes — the ledger the credit and gift-card keys govern.
- Order changes — what
features.order_versioningturns on. - Shipping — shipping zones and rates.
- Pickup — click and collect, and
shipping.pickup_enabled. - Coupons — coupon codes, stacking and campaigns.
- Checkout fields — what
checkout.fields_enabledgoverns. - Countries and addresses — address layouts, country rules and the
address.*keys. - Cart recovery — restore links, reminders, share links and the email log.
- Digital products — download grants and delivery methods.
- Customer accounts — store accounts and the account area.
- Hardening — order-link expiry, rate limits, trusted proxies, and the web-server rules the data directories need.
- Analytics — what the four
reports.*keys govern. - Dashboard alerts — what the two
alerts.*keys govern. - Product lifecycle — revisions, scheduling and queued changes.
- Deposits — part payment and the deposit keys.
- Shipping rates — carrier rates, packages and cost expressions.
- Tax drivers — what
tax.enginepoints at. - CLI — every
bin/plugin kahunacartcommand. - Object templates — the formats the store edits rather than configures.
- Storefront JSON API — what
storefront_api.enabledanswers. - Outbound webhooks — what the four
webhooks.*keys govern. - Demo store — what
demo.readonlyanddemo.protected_customersdo. - Currencies — selling in more than one currency