Skip to content

Powered by Grav CMS + Helios Theme

Troubleshooting

Common KahunaCart symptoms, their causes, and the fix for each.

Start with the status command

BASH
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.

BASH
bin/plugin kahunacart webhooks
bin/plugin kahunacart webhooks --provider stripe --limit 50
TXT
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:

BASH
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:

  1. Move both directories outside the document root. Set database.sqlite.path and downloads.path to absolute paths the web server never serves.
  2. 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.

BASH
bin/grav clear-cache

Or clear just the route table:

BASH
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.

  1. Run bin/plugin kahunacart status and check whether the provider is listed.
  2. If it is missing, fill in the credential fields for the mode you are in. Sandbox and live credentials are separate fields.
  3. 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.

  1. Prove the endpoint is reachable from outside. The endpoint is {route}/webhook/{slug}, which with the default route is /shop/webhook/stripe:
BASH
curl -i -X POST https://your-site.com/shop/webhook/stripe -d '{}'
  1. A JSON body with a 400 is correct: the route matched and the signature check rejected your empty request.
  2. On a 404, check route in 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.

  1. Confirm the secret belongs to the mode the store is running in.
  2. For Stripe, check the server clock. An empty webhook secret rejects everything; it does not mean "unverified is allowed".
  3. For Polar, enter the secret string exactly as given. Do not pre-decode it.
  4. For PayPal, put the webhook object's ID (like 1JE43649LP004314D) in sandbox_webhook_id or live_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.

  1. Check the webhook log for a delivery at that time.
  2. Open the order's transaction ledger in the admin. A webhook that never landed shows a pending purchase that never went to success.
  3. 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.

  1. Check the queue. A growing pending count means the worker is not running.
BASH
bin/plugin kahunacart status
  1. Prove the worker itself works.
BASH
bin/plugin kahunacart work
  1. If that processes jobs, install the scheduler in cron.
BASH
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.

  1. Read the failures.
SQL
SELECT id, type, attempts, last_error FROM kahunacart_jobs
WHERE completed_at IS NULL AND attempts >= max_attempts;
  1. Fix the cause. The most common last_error is Email plugin is not available: install and configure the Email plugin.
  2. Retry a failed job by resetting its counters.
SQL
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.

  1. 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.
  2. Do one of three things, all described under Going live → Worker environment: move the settings into user/config/plugins/email.yaml, set GRAV_ENVIRONMENT=<host> in the .env file in the Grav root, or start the cron line with GRAV_ENVIRONMENT=<host>.
  3. 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.

  1. Set cart.abandoned_enabled to true.
  2. 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.
  3. 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.

BASH
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.

  1. Wait ten minutes for the lock to expire.
  2. 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.

  1. List the live holds.
SQL
SELECT variant_id, SUM(qty) FROM kahunacart_stock_holds
WHERE expires_at > strftime('%s','now') GROUP BY variant_id;
  1. Check the worker is running. Holds expire after stock.hold_minutes, default 15, and are swept on every worker tick, so a stopped worker keeps stock locked.
  2. To disable holds entirely, set stock.hold_minutes to 0.

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.

  1. After a partial refund, adjust stock by hand.
  2. 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.
  3. 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.

  1. Check logs/grav.log. The failure is recorded with the grant id and the path it pointed at.
  2. Fix the file.
  3. 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.

  1. Set downloads.method to php while you sort it out.
  2. For xaccel, add an nginx internal location whose prefix matches downloads.xaccel_prefix exactly, with an alias pointing at the files root. Trailing slashes on both matter.
  3. For xsendfile, set XSendFile On and an XSendFilePath covering the files root. XSendFilePath is 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.

  1. Check both ends:
BASH
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
  1. The second command should answer 206 Partial Content. If it answers 200, 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.

  1. Find the files:
BASH
bin/plugin kahunacart downloads:check
  1. The table says whether the bytes are missing or only outside the roots.
  2. Move the files, or add the old location to downloads.allowed_roots. See Hardening → Restrict where downloadable files may live.

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.

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.

  1. Find the bucket: grep logs/grav.log for rate limit reached, which logs the bucket and the address.
  2. Raise that bucket under security.rate_limits, or set its limit to 0 to 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.

  1. Check the address has a country. Without one the zone engine emits nothing.
  2. Check your zone criteria. Regions match on the name, not a code, and postcodes are normalized before comparison.
  3. Check the tax class. A reduced-rate row does nothing to a cart of standard-rate goods.
  4. 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.

  1. Compare the cart's items subtotal against min_total_minor.
  2. Add up qty × weight_grams across the physical lines and see which band that lands in. A cart heavier than the top band's max_grams is offered nothing.
  3. 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 status lists 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 status output.
  • The relevant last_error from kahunacart_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.