Skip to content

Powered by Grav

Coming soon — KahunaCart is in final testing. Join the list and be first to know. Join the list

Digital products and downloads

This page explains how KahunaCart delivers downloadable files to customers. It is for the store administrator who sells digital products.

A product with type: digital skips shipping and can carry downloadable files. When someone buys it, KahunaCart mints a download grant: a per-order, per-file, per-line permission with its own random token, download counter and expiry. A link to the grant appears on the order page.

Product files

Attach files to a product at Products → a product → Files, or over the API. Each attachment carries these fields:

Field What it does
name The display name, and the filename the customer's browser saves. Independent of the name on disk.
variant_id Null means every buyer of the product gets it. Set it to pin the file to one variant.
download_limit Downloads allowed per grant. Null falls back to downloads.default_limit.
expiry_days Grant lifetime in days. Null falls back to downloads.default_expiry_days.
position Admin display order.

Three rules apply to every attachment:

  • The client never chooses where a file lands. The stored path is derived server-side from the product directory, a sanitized basename and a random suffix. Two uploads of manual.pdf do not collide.
  • The bytes are immutable. You can edit an attachment's name, limit, expiry and position, but not its content. To replace a file, upload a new one.
  • Deleting an attachment leaves existing grants in place. The bytes go, and those grants then deny with not_found.

downloads.max_upload_mb caps a single admin upload at 512 MB by default. PHP's own upload_max_filesize and post_max_size still apply, and are usually the lower limit.

Files that belong to a version

Everything above describes an evergreen file: attach it to the product and every buyer of that product gets it, for as long as their grant lasts. That is the whole story for a manual, a sample pack or a font family, and it is what every file on your store is until you say otherwise.

A product that ships a 1.0 and then a 1.1 needs the other kind. Attach a file to a release instead and it becomes a build of a stated version, with its own date and notes, grouped under a version heading on the customer's downloads page. Releases are optional, they are part of the base plugin rather than any add-on, and by default every buyer gets every published version. See Product releases for how to cut one, publish from CI in a single call, and gate versions behind an updates window if you sell software that way.

The files root

Downloadable files live in user/data/kahunacart/files by default. Override the location with downloads.path. Absolute paths and Grav stream URIs both work.

On first use, KahunaCart creates the directory and writes a deny-all .htaccess plus an empty index.html. Stock Grav server rules do not block direct requests under user/, so without the deny rule a customer who guessed /user/data/kahunacart/files/ebook.pdf would bypass the grant check.

Warning

The .htaccess protects Apache only. nginx and Caddy ignore it. On those servers, either keep the files root outside the document root, which is the recommended arrangement, or add a location block that denies it. The xaccel delivery method needs such a block anyway, marked internal.

Download grants

A grant is one row per (order, item, file) triple. It carries a 128-bit random hex token, which is the customer's only handle on it, plus downloads_used, download_limit, expires_at and revoked_at.

Limits and expiry are resolved once, at grant time, and frozen onto the grant. The file's own values win, and the store defaults fill the gaps. Later catalog edits do not reach back into orders already placed.

Granting is idempotent, so a webhook redelivery or a job retry mints nothing new and resets no counter.

When grants are minted

downloads.grant_on decides when a grant is created:

Value Meaning
paid (default) Wait for funds to be captured.
completed Grant as soon as the order is placed.

Use completed for offline payment methods, so the customer does not wait days for a bank transfer to clear.

Under paid, instant-capture providers complete the order already paid and never fire a separate "order paid" event. KahunaCart also grants at completion when the order completed already-paid, which covers every instant-capture provider.

Important

Grants are created by a queued job (order.grant_downloads), so the scheduler has to be running or customers never get their links. See Troubleshooting.

Download authorization

The customer's link is {route}/download/{token}.

Authorization and consumption are a single conditional UPDATE. Two concurrent requests on a grant with one download left can never both succeed, on any of the three database engines and without SELECT FOR UPDATE. The loser is re-examined to produce a precise denial reason.

The authorization query
SQL
UPDATE kahunacart_download_grants
SET downloads_used = downloads_used + 1, updated_at = ?
WHERE id = ?
  AND revoked_at IS NULL
  AND (download_limit IS NULL OR downloads_used < download_limit)
  AND (expires_at IS NULL OR expires_at > ?)

Denial reasons

Reason What the customer sees
not_found We could not find that download. The link may have been mistyped or the file may have been removed.
revoked This download has been withdrawn. Get in touch if you think that is a mistake.
expired This download link has expired.
exhausted You have used all the downloads included with this purchase.
order_refunded This order was refunded, so its downloads are no longer available.
unreadable The file is temporarily unavailable. Please try again shortly.

The ladder is checked in the order revoked → expired → exhausted. Denials render the kahunacart-denied template, which carries a kahunacart-denied--{reason} class so a theme can style each case.

Note

The counter is spent before anything touches the disk, which is what makes the check atomic. A grant pointing at a missing file therefore costs the customer a download and shows the unreadable message. The failure is logged with the grant id and the path.

Delivery methods

downloads.method picks how the bytes reach the customer. Anything unrecognized falls back to php.

Every method sends an RFC 5987 Content-Disposition, so the customer gets the catalog filename, and X-Content-Type-Options: nosniff, so a stored .html can never render inline.

php — works everywhere

No server configuration is needed. PHP tears down every output buffer, disables zlib compression, lifts the time limit and streams the file itself. Content-Length is sent, so the browser can show a progress bar.

A PHP worker is held for the entire transfer. That is fine for a 2 MB PDF and poor for a 400 MB video on a server with a small worker pool.

The response carries X-Accel-Redirect and PHP exits immediately. nginx re-requests the file internally and streams it with sendfile(). No PHP worker is held, and no Content-Length is sent because nginx sets it.

Add an internal location whose prefix matches downloads.xaccel_prefix and whose alias points at the files root:

NGINX
location /kahunacart-files/ {
    internal;
    alias /var/www/example.com/user/data/kahunacart/files/;
}

Three things to get right:

  • The internal directive is not optional. Without it, that location is a public file listing of everything you sell.
  • The trailing slashes matter. An alias with a trailing slash on a prefix location with a trailing slash is what makes the path concatenation work.
  • The prefix must match downloads.xaccel_prefix exactly. The default is /kahunacart-files/. If you change one, change the other.

Each path segment is encoded before it goes out, so spaces and unicode filenames survive nginx percent-decoding the URI. If your files root sits outside the document root, point alias there.

xsendfile — Apache with mod_xsendfile, or LiteSpeed

The response carries X-Sendfile with the file's absolute path. The module streams it and strips the header before it reaches the client.

Install mod_xsendfile, then add this to your vhost:

APACHE
XSendFile On
XSendFilePath /var/www/example.com/user/data/kahunacart/files
  • XSendFilePath is mandatory. mod_xsendfile refuses any path not under a configured XSendFilePath. Point it at the files root and nothing wider.
  • XSendFilePath is a server-level or vhost-level directive. It cannot go in .htaccess.
  • LiteSpeed implements the same header natively and enables the equivalent by default, so it usually needs no configuration.

Verify a delivery method

  1. Set downloads.method to the method you want.
  2. Buy a test digital product.
  3. Download the file from the order page.

The file downloads with its catalog filename. A 404, an empty file, or the raw X-Accel-Redirect path shown as text means the server side is not configured.

Warning

Switch back to php while you sort out a broken delivery method. It fails silently from the customer's side.

Revoke a grant

Revoke a grant to answer a chargeback, or when a link turns up somewhere it should not have.

  1. In the admin, open the order.
  2. Revoke the grant.

Or call the API:

TXT
POST /kahunacart/orders/{id}/downloads/{grantId}/revoke

Revocation stamps revoked_at and leaves the row in place, preserving the record of who was given what. Revoked grants are filtered out of the customer's order page, so there is nothing to click rather than a dead link.

Revoking requires the kahunacart.orders.manage permission. Listing an order's grants requires kahunacart.orders.view.

Caution

There is no un-revoke. Reinstating access means granting again.

Refunds

A fully refunded order kills download access outright. Authorization checks the order's payment status and denies with order_refunded, whatever the individual grants say. A full refund also revokes every live grant on the order, so correcting a refund with a later transaction requires granting again.

A partially refunded order does not affect downloads. Partial refunds are routinely a shipping adjustment or one line of a mixed order.

To cut off access on a partial refund, revoke the grants explicitly when you issue it. There is no setting for this; it is a per-order action.