Troubleshooting
Common KahunaCart symptoms, what causes each one, and how to fix it. It is for the store administrator.
Start with the status command
bin/plugin kahunacart status
It reports the database engine in use, whether migrations are pending, how the job queue is doing, and which payment providers registered.
Read the webhook log
Start here for any webhook problem. Every inbound delivery leaves exactly one row, including the ones that did nothing, so the log answers whether a delivery arrived and what this store made of it.
bin/plugin kahunacart webhooks
bin/plugin kahunacart webhooks --provider stripe --limit 50
Recent deliveries (3 of 3)
------------------- -------- ------------------- -------- ----------- --------------------------------
Received (UTC) Provider Action Status Reference Error
------------------- -------- ------------------- -------- ----------- --------------------------------
2026-08-19 09:14:02 stripe — 400 — signature or payload rejected…
2026-08-19 09:02:55 stripe payment_completed 200 pi_3Nx… —
2026-08-19 08:41:17 stripe ignored 200 — —
------------------- -------- ------------------- -------- ----------- --------------------------------
Per provider
--------- ------------ -------- ---------------- ---------------------
Provider Deliveries Failed Current streak Last (UTC)
--------- ------------ -------- ---------------- ---------------------
stripe 3 1 1 2026-08-19 09:14:02
--------- ------------ -------- ---------------- ---------------------
| What you see | What it means | What to do |
|---|---|---|
| No rows for a provider you expect | Nothing is reaching the endpoint | See No webhook deliveries arrive |
400 with invalid |
The signature check refused the delivery | See A webhook is refused with a 400 |
404 |
The delivery reached Grav but named a provider slug that is not installed | Check the URL registered at the provider, and that the plugin is enabled |
200 with ignored |
Healthy. An event type the provider handles by not acting on it | Nothing |
500 with an exception class |
The delivery was verified and this store broke while processing it | Read logs/grav.log. The provider will retry |
Current streak above zero |
Deliveries in a row that have not landed since that provider's last good one | Check the webhook secret, then the endpoint URL |
A streak never disables anything. See Payment providers → Failure streaks.
The log keeps the first 2 KB of the body for verified deliveries only; a refused delivery records its length and failure class but not its content. Rows are purged after payments.webhook_log_days days, default 30.
Check your SQLite file is not web-readable
The database lives under user/, where stock Grav server rules do not block .sqlite downloads. KahunaCart writes a deny-all .htaccess and an empty index.html into both the database and downloadable-files directories, and chmods the database file to 0640.
Warning
That covers Apache only. nginx and Caddy ignore .htaccess entirely. A 200 on the first check below means your entire order history, including every customer email and address, is downloadable by anyone who guesses the path.
Test it from outside:
curl -sI https://your-site.com/user/data/kahunacart/db/kahunacart.sqlite | head -1
curl -sI https://your-site.com/user/data/kahunacart/files/ | head -1
Anything other than 403 or 404 is a problem. Two fixes, in order of preference:
- Move both directories outside the document root. Set
database.sqlite.pathanddownloads.pathto absolute paths the web server never serves. - Add an explicit deny rule. On nginx:
location ~ ^/user/data/kahunacart/ { deny all; return 404; }
Note
The xaccel download method needs nginx to read the files directory through an internal location. That is compatible with the deny rule above, because internal locations are not reachable from outside. See Digital products.
Admin and API problems
Admin screens are blank, or every API call 404s
Cause. The API plugin's route cache is stale after a plugin update. The cache under cache://api/ is keyed on the set of enabled plugins, and updating an already-enabled plugin leaves that set identical, so the old route table is reused. Route caching is skipped when system.debugger.enabled is true, so this only bites in production.
Fix.
bin/grav clear-cache
Or clear just the route table:
rm -rf cache/api
The same fix applies after updating any KahunaCart provider plugin that registers its own API routes.
A payment provider does not appear at checkout
Cause. The provider plugin refused to register because it is half-configured. A partial install never reaches customers.
| Provider | Registers only when |
|---|---|
| Stripe | enabled is on and the active mode's secret key is non-empty |
| Polar | enabled is on and the active mode's access token is non-empty |
| PayPal | enabled is on and both the active mode's client id and secret are non-empty |
Fix.
- Run
bin/plugin kahunacart statusand check whether the provider is listed. - If it is missing, fill in the credential fields for the mode you are in. Sandbox and live credentials are separate fields.
- If it is listed but still absent from checkout, clear the route cache and confirm the plugin is enabled in Grav.
Webhook problems
No webhook deliveries arrive
Cause. The endpoint is not being reached. This is a routing problem, not a signature one.
Fix.
- Prove the endpoint is reachable from outside. The endpoint is
{route}/webhook/{slug}, which with the default route is/shop/webhook/stripe:
curl -i -X POST https://your-site.com/shop/webhook/stripe -d '{}'
- Read the response. A JSON body with a
400is correct: the route matched and the signature check rejected your empty request. - On a
404, checkroutein the config and the URL registered at the provider.
A webhook is refused with a 400
Cause. Signature verification failed. Every provider verifies before it touches order state, so nothing changed.
| Provider | Scheme | Common causes |
|---|---|---|
| Stripe | HMAC-SHA256 over the raw body against {mode}_webhook_secret, 300-second tolerance |
Secret from the wrong mode; using the dashboard secret while forwarding with stripe listen, which prints its own; server clock drift beyond 300 seconds |
| Polar | Standard Webhooks (webhook-id, webhook-timestamp, webhook-signature), ±300 seconds |
The endpoint is not set to the Raw format in Polar; the secret was pre-decoded |
| PayPal | An API call to PayPal, one per accepted webhook | A missing Paypal-* header; a cert URL that is not on a PayPal host; no webhook ID configured |
Fix.
- Confirm the secret belongs to the mode the store is running in.
- For Stripe, check the server clock. An empty webhook secret rejects everything; it does not mean "unverified is allowed".
- For Polar, enter the secret string exactly as given. Polar's own docs describe a base64 round trip that cancels out, so do not pre-decode it.
- For PayPal, put the webhook object's ID (like
1JE43649LP004314D) insandbox_webhook_idorlive_webhook_id, not a secret.
Note
PayPal's Webhooks Simulator sends events with valid transmission headers that do not verify against a real webhook ID. Use it to check routing, not to complete an order.
A customer paid but the order is still a cart
Cause. The completion webhook never arrived. The payment-return page the customer saw is a holding page, and the webhook is authoritative.
Fix.
- Check the webhook log for a delivery at that time.
- Open the order's transaction ledger in the admin. A webhook that never landed shows a pending purchase that never went to success.
- Redeliver the webhook from the provider's dashboard.
Redelivering by hand is safe. Completion returns the already-completed order, the transaction ledger recognises a redelivered success, download grants skip triples that already have one, and PayPal captures use an idempotency key where ORDER_ALREADY_CAPTURED counts as success.
Job and email problems
Emails never arrive, jobs pile up
Cause. Order confirmations, download grants, abandoned-cart reminders and catalog sync all go through the job queue, which is drained by a scheduled command. If Grav's scheduler is not in cron, none of them happen.
Fix.
- Check the queue. A large and growing
pendingcount means the worker is not running.
bin/plugin kahunacart status
- Prove the worker itself works.
bin/plugin kahunacart work
- If that processes jobs, install the scheduler in cron.
bin/grav scheduler --jobs # is kahunacart-jobs listed?
bin/grav scheduler --install # prints the crontab line to add
bin/grav scheduler --run # force a run now
KahunaCart registers as kahunacart-jobs on a * * * * * schedule, so a working scheduler drains the queue every minute.
Jobs fail rather than pile up
Cause. Jobs are being claimed and throwing. A non-zero failed count in status means they have exhausted their attempts. Failures retry with exponential backoff, capped at one hour, up to three attempts, then sit in the failed bucket with last_error recording why.
Fix.
- Read the failures.
SELECT id, type, attempts, last_error FROM kahunacart_jobs
WHERE completed_at IS NULL AND attempts >= max_attempts;
- Fix the cause. The most common
last_errorisEmail plugin is not available. The Email plugin is not a declared dependency, so email jobs fail cleanly until it is installed and configured. - Retry a failed job by resetting its counters.
UPDATE kahunacart_jobs SET attempts = 0, run_after = 0, locked_at = NULL, locked_by = NULL
WHERE id = ?;
Abandoned-cart reminders never send
Cause. The sweep is off by default, or the carts do not qualify.
Fix.
- Set
cart.abandoned_enabledtotrue. - Check the carts qualify. A cart is mailed only if it still is a cart, has an email address, has items, has been untouched for
cart.abandoned_hours, and has never been mailed. - Watch the worker's output. The sweep runs directly on every worker tick rather than being queued, and its failures are reported there without being fatal.
At most 25 carts are mailed per sweep, so a backlog drains over successive runs. Only customers who submitted the checkout form and then did not pay get a reminder, because a cart abandoned before checkout has no email address.
Note
One reminder per cart, ever.
Migration problems
"Column not found" or "no such table"
Cause. Migrations have not run. On MySQL or PostgreSQL the default auto_migrate: sqlite policy does not auto-apply them.
Fix.
bin/plugin kahunacart migrate --status # what is pending
bin/plugin kahunacart migrate # apply it
Make the apply step part of your deploy pipeline.
A migration seems stuck
Cause. A crashed run left the database lock held. The lock has a 600-second TTL.
Fix.
- Wait ten minutes. The lock expires by itself and the next run takes over.
- Re-run
bin/plugin kahunacart migrate. Migrations are step-level and idempotent, so a re-run after a partial failure picks up where it left off.
Caution
There are no down migrations. The recovery model is restore from backup, so take one before a schema-changing update on a store with real orders in it.
Data is silently truncated on MySQL
Cause. MySQL is not in strict mode, so it truncates oversized values rather than refusing them. A 200-character product title becomes 190 characters.
Fix. Turn on strict mode on the database server. bin/plugin kahunacart status warns when it is off, and KahunaCart does not compensate.
Stock and hold problems
"Product is out of stock" on something you have in stock
Cause. Stock holds. Other customers sitting at a payment provider have that stock reserved.
Fix.
- List the live holds.
SELECT variant_id, SUM(qty) FROM kahunacart_stock_holds
WHERE expires_at > strftime('%s','now') GROUP BY variant_id;
- Check the worker is running. Holds expire after
stock.hold_minutes, default15, and are swept on every worker tick, so a stopped worker lets expired holds keep stock locked. - To disable holds entirely, set
stock.hold_minutesto0.
Stock is negative
Cause. backorders is set to something other than deny on that variant, so the deficit is shown as a number.
Fix. Set backorders to deny on the variant if you want stock to stop at zero.
An order failed at completion with an insufficient-stock error
Cause. Two customers raced for the last unit and one lost.
Fix. None needed. The losing order rolled back to a cart in full: no order number, no stock deducted, nothing half-written. The customer can retry once there is stock.
Stock was not returned after a refund
Cause. A full refund restocks; a partial one does not, because nothing records which items the money was for.
Fix.
- After a partial refund, adjust stock by hand.
- If a full refund left the count alone, check that the variant is stock-managed. An unmanaged variant has no count to return units to.
- Check whether the order had already been reversed. The reversal runs once per order, so a second full refund on an order that was already given back changes nothing.
Download problems
Customers get "The file is temporarily unavailable"
Cause. The grant is valid but the bytes cannot be read.
Fix.
- Check
logs/grav.log. The failure is recorded with the grant id and the path it pointed at. - Fix the file.
- Consider granting again. The customer was already charged a download for the failed attempt, because the counter is spent before anything touches the disk.
Downloads 404, return an empty file, or return a path as text
Cause. downloads.method is set to xaccel or xsendfile and the server side is not configured. This fails silently from the customer's side.
Fix.
- Set
downloads.methodtophpwhile you sort it out. - For
xaccel, add an nginxinternallocation whose prefix matchesdownloads.xaccel_prefixexactly, with analiaspointing at the files root. Trailing slashes on both matter. - For
xsendfile, setXSendFile Onand anXSendFilePathcovering the files root.XSendFilePathis a server- or vhost-level directive and cannot go in.htaccess.
Exact snippets are in Digital products → Delivery methods.
Large downloads time out or exhaust memory
Cause. The php delivery method holds a PHP worker for the whole transfer, so a handful of concurrent slow downloads can starve the entire site.
Fix. Set downloads.method to xaccel or xsendfile.
Coupon problems
A code works on the cart but is refused at checkout
Cause. The per-user usage limit needs an email address to enforce, and the cart page usually does not have one. The check becomes possible only once checkout knows who the customer is.
Fix. None needed. This is the correct order of events.
A coupon was refunded but stays used up
Cause. Only a full refund releases the redemption. A partial refund leaves the order and its discount standing.
Fix. Raise the coupon's usage_limit by hand if that matters for a partial credit.
Tax problems
Tax is charged when tax.rate is set to '0'
Cause. You have tax zones. The zone engine takes over the moment one zone exists and does not read tax.rate at all.
Fix. Delete the zones, or set their rates to zero.
Note
A merchant-of-record provider needs no zero. The order is recalculated without local tax when the customer checks out through it. The cart page still shows a tax estimate before a payment method is picked, and that estimate is gone from the completed order. See Tax → Merchant-of-record providers.
Tax is not charged at all
Cause. One of three things, in order of likelihood: no country on the address, no zone matches, or the rate's tax class is not in the cart.
Fix.
- Check the address has a country. An address without one cannot identify a jurisdiction, so the zone engine emits nothing rather than guessing.
- Check your zone criteria. Regions match on the name, not a code, and postcodes are normalized before comparison.
- Check the tax class. A reduced-rate row does nothing to a cart of standard-rate goods.
- Check
tax.basis, which decides which address is matched, with the other as a fallback.
Shipping problems
Checkout keeps sending customers back with "Please choose a shipping method"
Cause. The two-step flow working as designed: the first submit stores the address, and the second render resolves the zone and shows real prices. See Storefront → The two-step shipping flow.
Fix. If it never resolves, either the address matches no zone or the zone offers this cart no method. Check free-shipping thresholds, which are tested against the items subtotal and can be pushed out of reach by a discount.
A shipping method vanished from checkout
Cause. An availability rule removed it: either a free-shipping threshold the cart has not reached, or weight bands that do not cover this order's weight.
Fix.
- Compare the cart's items subtotal against
min_total_minor. - Add up
qty × weight_gramsacross the physical lines and see which band that lands in. A cart heavier than the top band'smax_gramsis offered nothing at all, which is the intended answer. - Remember that weights come from each line's snapshot, not the live variant. Fixing a product's weight changes new carts only.
See Shipping → Weight-based rates.
"We are unable to ship to that address"
Cause. The address matched no shipping zone.
Fix. Add a catch-all zone with no criteria as the last zone in the list, so everywhere else gets a fallback rate.
Getting help
Gather these before you report an issue:
bin/plugin kahunacart statusoutput.- The relevant
last_errorfromkahunacart_jobs. - Grav's log at
logs/grav.log, where webhook, download and job failures all land. - The order's transaction ledger from the admin order detail.
Report issues at the premium issue tracker.