Troubleshooting
Common KahunaCart symptoms, their causes, and the fix for each.
Start with the status command
bin/plugin kahunacart status
It reports the database engine, pending migrations, the job queue and which payment providers registered.
bin/plugin kahunacart check runs the pre-launch checks and prints a fix for each one that is not green. check --report prints the redacted support report to attach to a ticket. See Going live.
Read the webhook log
Start here for any webhook problem. Every inbound delivery leaves one row, including the ones that did nothing.
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. Rows are purged after payments.webhook_log_days days, default 30.
Check your SQLite file is not web-readable
The database lives under user/. 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. 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. Copy-and-paste rules for nginx, Caddy and an Apache vhost are in Hardening → Deny the data directories.
Note
The xaccel download method needs nginx to read the files directory through an internal location. That works with the deny rules, because internal locations are not reachable from outside. See Hardening → Serve downloads through the web server.
Admin and API problems
Admin screens are blank, or every API call 404s
Cause. The API plugin's route cache under cache://api/ is stale after a plugin update. It is keyed on the set of enabled plugins, which an update does not change. 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 is half-configured, so it did not register.
| 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 '{}'
- 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. Nothing changed on the order.
| 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. 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 only a holding page.
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 is idempotent, the ledger recognises a redelivered success, download grants are not duplicated, 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. If Grav's scheduler is not in cron, none of them happen.
Fix.
- Check the queue. A 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 throwing. Failures retry with exponential backoff, capped at one hour, up to three attempts, then sit in the failed bucket with last_error recording why. A non-zero failed count in status means they have exhausted their attempts.
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: install and configure the Email plugin. - 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 = ?;
"Relay access denied", but test emails from the admin work
Cause. The admin sends with the site's settings and the worker sends with its own, and on a store with a user/env/<host>/ folder those can be two different sets. Admin Next saves the Email plugin's settings into user/env/<host>/config/plugins/email.yaml when that folder exists. Cron runs the worker as the cli environment, which never reads it, so the worker connects to the mail server with whatever user/config/plugins/email.yaml holds, often no login, and the server refuses to relay. The job's last_error says so, while the admin's own test email, sent from a web request, goes out fine.
Fix.
- Run
bin/plugin kahunacart check. The Worker environment check names the folder, the settings in it the worker misses, and the environment the worker last ran as. - Do one of three things, all described under Going live → Worker environment: move the settings into
user/config/plugins/email.yaml, setGRAV_ENVIRONMENT=<host>in the.envfile in the Grav root, or start the cron line withGRAV_ENVIRONMENT=<host>. - Retry the failed jobs from the admin's Jobs screen.
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 is still 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 on every worker tick, and its failures are reported there.
At most 25 carts are mailed per sweep. 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 for the lock to expire.
- Re-run
bin/plugin kahunacart migrate. Migrations are step-level and idempotent, so it picks up where it left off.
Caution
There are no down migrations. Recovery 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 instead of refusing them.
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
"Out of stock" or "not enough left" on something you have in stock
Cause. Stock holds: other customers 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 keeps 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.
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, with no order number and no stock deducted. 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.
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 failed attempt already cost the customer a download.
Downloads 404, return an empty file, or return a path as text
Cause. downloads.method is xaccel or xsendfile and the server side is not configured.
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
Cause. The php delivery method holds a PHP worker for the whole transfer, so a handful of concurrent slow downloads can starve the whole site.
Fix. Set downloads.method to xaccel or xsendfile.
Memory is not the cause; the php method streams in fixed chunks. If a download does exhaust memory, something between PHP and the socket is buffering the whole body: an output buffer a plugin never closed, or gzip applied by the web server. Check the download response for a Content-Encoding: gzip.
Customers cannot resume a large download
Cause. A proxy in front of the store is stripping the Range header or the range response. The php method advertises Accept-Ranges: bytes and honours Range.
Fix.
- Check both ends:
curl -sI 'https://your-site.com/shop/download/<token>' | grep -i accept-ranges
curl -sI -H 'Range: bytes=0-99' 'https://your-site.com/shop/download/<token>' | head -1
- The second command should answer
206 Partial Content. If it answers200, the proxy is not passing the header through.
A file that downloaded before now says "temporarily unavailable"
Cause. The path no longer resolves inside an allowed root: downloads.path changed without the old location being added to downloads.allowed_roots, or a symlink inside the files root points outside the allowlist.
Fix.
- Find the files:
bin/plugin kahunacart downloads:check
- The table says whether the bytes are missing or only outside the roots.
- Move the files, or add the old location to
downloads.allowed_roots. See Hardening → Restrict where downloadable files may live.
Order link and rate limit problems
A customer says their receipt or invoice link no longer works
Cause. Order links are signed and expire after security.order_link_days for receipts, or security.invoice_link_days for invoices and PDFs. Links emailed before signatures existed land on the same page.
Fix. None needed. The page they land on has a button that emails a fresh link to the address on the order. A registered customer can also sign in and open the order from Account → Orders, where the links never expire.
A pay link says the link expired
Cause. Two different things say that, with different fixes.
| Message | What it is | Fix |
|---|---|---|
| "This link has expired" | The signed link | The button on the page emails a fresh one |
| "This payment link has expired", on the pay page | The order's own deadline, orders.pay_link_days from when the link was issued |
Open the order in the admin and press Reissue, or Reissue & email |
See Orders and the admin → Pay links.
Everybody is getting 429s at once
Cause. The store is behind a proxy and security.trusted_proxies is empty, so every visitor shares one rate-limit bucket.
Fix. Fill in the proxy's addresses or CIDR ranges. Cloudflare publishes its ranges at cloudflare.com/ips; a load balancer inside your own network is usually one entry such as 10.0.0.0/8. See Hardening → Trusted proxies.
Warning
Do not put a range you do not control in trusted_proxies. Trusting X-Forwarded-For from an address anybody can connect from turns every rate limit into a suggestion.
One legitimate customer keeps getting 429s
Cause. The bucket they are hitting is too tight for how your store is used, or an integration is polling a route.
Fix.
- Find the bucket: grep
logs/grav.logforrate limit reached, which logs the bucket and the address. - Raise that bucket under
security.rate_limits, or set its limit to0to turn it off. See Hardening → Rate limits.
The 429 carries a Retry-After header.
Coupon problems
A code works on the cart but is refused at checkout
Cause. The per-user usage limit needs an email address, which the cart page usually does not have. The check runs once checkout knows who the customer is.
Fix. None needed.
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. Once one zone exists, tax.rate is not read 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, so the cart's tax estimate is gone from the completed order. See Tax → Merchant-of-record providers.
Tax is not charged at all
Cause. 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. Without one the zone engine emits nothing.
- 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: 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. - Weights come from each line's snapshot, so 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.
More than one currency
A store that sells in more than one currency has a few failures of its own. The guide has them all, with the cause of each: Currencies → Troubleshooting. The common ones:
- "Not available in EUR" on a product — it has no euro price. Type one, or find the products missing one with the Missing a price in filter on the product list.
- "No payment method is available for orders in EUR" at checkout — nothing takes euro.
bin/plugin kahunacart statuslists the payment methods for each currency. - A listing sorts newest first in a new currency — the catalog index hasn't been rebuilt. Run
bin/plugin kahunacart index --rebuild. - A shipping method or a coupon is missing in euro — it has no amount in euro, and a missing amount means it doesn't apply there.
Getting help
Gather these before asking for help:
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.
Then search the help center, and if nothing there fits, open a request from it with what you gathered. Requests are private: only you and the KahunaCart team see them.
Send a security problem privately to [email protected] instead, never to Discord or anywhere else public. See Hardening → Reporting a vulnerability.