Digital products and downloads
This page explains how KahunaCart delivers downloadable files to customers.
A product with type: digital skips shipping and can carry downloadable files. A physical product can carry files too, and one of its variants can be digital: a record sold as pressings and a download is one product. See Digital variants and Files on physical products.
What decides shipping is the product type's ships() declaration. Download grants do not depend on it: every line of a product with files earns them, whatever its type, so changing a product's type never stops its files being delivered.
When someone buys a product with files, KahunaCart creates 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.
A download only runs out when you track its stock, for a limited run or to hold it back until release. At zero it says Not available by default, or Coming soon, or your own words, never "Out of stock". See When a download runs out.
Product files
Attach files to a product at Products → a product → Files, or over the API. Each attachment has 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, whichever variant they bought. Set it to pin the file to one variant, and only that variant's buyers get it. The admin's For select on the upload and on each file sets it on a product with more than one variant. The API refuses a variant of another product with 422. |
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 cannot choose the storage path. The stored path comes from the product directory, a sanitized basename and a random suffix. Two uploads of
manual.pdfdo not collide. - The bytes cannot be changed. 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 on physical products
Files work the same on a physical product. A record whose pressings each come with the download carries the album's file with no variant, and every buyer of every pressing gets it. A file pinned to one variant goes to that variant's buyers only.
- Grants are minted for those lines on the same
downloads.grant_onrule as a digital product's. - Give to past buyers works the same.
- A pre-order holds the grants until its date.
- A line refunded in full loses its downloads like a digital line does. See Refunding one line at a time.
On a physical product the Downloadable files panel sits behind Add downloadable files until it has a file or a variant is digital.
Digital variants
A variant of a physical product can be digital: tick Digital (no shipping) on the variant in the product editor (is_digital: 1 on the variant over the API, MCP, the catalog file and the CSV importer). A record sold as an amber LP, a black LP, a CD, a cassette and a download is one product with five variants, and the Download is the digital one.
A line of a digital variant is treated in every way as a line of a digital product. The cart freezes it onto the line as is_digital: 1 and nature: digital when it is added, so:
- It needs no parcel. No shipping is quoted for it, it weighs nothing, it never joins the package its product's shipping profile names, and a bag holding only digital lines asks for no shipping address.
- It is taxed as a digital good (EU digital services rules where a tax zone says so), and a digital-goods-only payment provider may take it.
- The order needs no fulfilling when every line is digital, packing slips and shipped emails leave it out, and the returns policy treats it as a download.
- The feeds and structured data state no shipping for that variant's own offer.
A bag with an amber LP and the Download ships the LP and asks for an address. The Download rides along. Its file is attached to the product with For set to the Download variant, or with no variant when every pressing comes with it too.
Stock works as on any variant: unmanaged unless you track it. Variants generated from options don't inherit the switch from the default variant, since a matrix of sizes is rarely all downloads. A digital product's variants are already digital, so the switch is shown only on products that ship.
Files that belong to a version
A file attached to the product is evergreen: every buyer of that product gets it for as long as their grant lasts. That suits a manual, a sample pack or a font family.
A product that ships a 1.0 and then a 1.1 needs the other kind. Attach the file to a release instead. It then 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 and part of the base plugin, 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.
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. 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, keep the files root outside the document root (recommended) or add a location block that denies it. Copy-and-paste rules for nginx, Caddy and Apache are in Hardening → Deny the data directories.
Allowed roots
downloads.allowed_roots lists the directories a purchased file may be served from. It defaults to [downloads.path].
The list is checked twice. At upload, a file stored outside an allowed root is deleted and the request returns 422. At delivery, a stored path that no longer resolves inside one is refused. Both checks run realpath() on each side, so a symlink, a .. or a bind mount is followed first.
Add a second root when files live in two places, which in practice means a migration:
downloads:
path: '/var/lib/kahunacart/files'
allowed_roots:
- '/var/lib/kahunacart/files'
- '/srv/legacy-downloads'
Roots are tried in order and the first one that holds the file wins. A configured root that does not exist on disk is dropped, not created.
Audit a store with bin/plugin kahunacart downloads:check. It lists every product file that cannot be delivered and says whether the bytes are missing or only outside the roots. See CLI → downloads:check.
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 change orders already placed.
Granting is idempotent. 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, KahunaCart also grants when an order completes 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.
Issuance runs through KahunaCart's compensating hooks, so it carries a declared undo. If a later step in the same flow fails, every grant the order was given is revoked again. Setting extensions.hooks.enabled to false puts issuance back on the direct path and removes that undo.
Pre-orders
A pre-order's grants are minted at purchase like any other, so the customer has the link from the start. The download is refused until the product's own release date (not_released, checked before a download is spent). The receipt, the account's Downloads list and the order emails show the file's name and "Soon · Nov 13" in place of the link.
A file added later with Give to past buyers follows the same rule, and so does a date you move: the product's date decides, not the one the order was sold with.
When the date passes, or you clear it, the worker lets every waiting line go and sends one It's out: your download is ready email (preorder-released, job order.preorder_released_email) to each order with a live download of that product, listing its files with their links.
- An order that waited for something that ships, an order that was never paid and a refunded one are let go with no email.
- Each line is claimed as it is let go, in the same transaction that queues the email, so a second run of the worker, or two workers at once, never send a second one.
- The subject is owner-editable on the Notifications screen like the other order emails, and the email can be switched off there.
- It needs the store's site address, like the sign-in link. Without one it waits and goes out once there is one.
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
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. |
not_released |
This isn't out yet. Your download opens on the release day, and we'll email you when it does. (A pre-order before its date.) |
unreadable |
The file is temporarily unavailable. Please try again shortly. |
The checks run 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. A grant pointing at a missing file 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 a fixed chunk at a time. Peak memory is one chunk however large the file is. Content-Length is sent from the file on disk, so the browser can show a progress bar.
It advertises Accept-Ranges: bytes and honours a Range header, so a dropped transfer resumes where it stopped. A range outside the file answers 416 with a Content-Range naming the size. A range header the server does not understand gets the whole file and a 200.
The cost is that 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.
Use php when you cannot configure the web server, or the files are small enough that holding a worker does not matter. Move to xaccel or xsendfile as soon as either is available. See Hardening → Serve downloads through the web server for the decision table.
xaccel — nginx, recommended
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:
location /kahunacart-files/ {
internal;
alias /var/www/example.com/user/data/kahunacart/files/;
}
Three things to get right:
- The
internaldirective is not optional. Without it, that location is a public file listing of everything you sell. - The trailing slashes matter. Both the
aliasand the location prefix need one. - The prefix must match
downloads.xaccel_prefixexactly. 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. 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:
XSendFile On
XSendFilePath /var/www/example.com/user/data/kahunacart/files
XSendFilePathis mandatory. mod_xsendfile refuses any path not under a configuredXSendFilePath. Point it at the files root and nothing wider.XSendFilePathis a server-level or vhost-level directive. It cannot go in.htaccess.- LiteSpeed supports the same header natively and enables it by default, so it usually needs no configuration.
Verify a delivery method
- Set
downloads.methodto the method you want. - Buy a test digital product.
- 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.
- In the admin, open the order.
- Revoke the grant.
Or call the API:
POST /kahunacart/orders/{id}/downloads/{grantId}/revoke
Revocation stamps revoked_at and leaves the row in place, so the record of who was given what survives. Revoked grants are hidden from the customer's order page.
Revoking requires the kahunacart.orders.manage permission. Listing an order's grants requires kahunacart.orders.view.
Caution
There is no un-revoke. To reinstate access, grant again.
Refunds
A fully refunded order ends download access. 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 means granting again.
A partial refund that names no lines does not affect downloads. Nothing records which items the money was for.
Refunding one line at a time
A refund that names its lines is more precise. A line's grants are revoked when its refunded quantity reaches the quantity that was bought, and not before. That holds for a pressing whose download came with it, too: refund the LP in full and its download goes with it.
- A customer refunded one of the three copies they bought keeps their download. They still paid for two.
- A customer refunded the whole line loses it.
- Nothing else on the order is touched.
Two details follow from that:
- Confirmed refunds only. The quantity is measured against refunds the provider has confirmed. A submitted refund does not revoke downloads until the provider confirms it, because a revocation cannot be reversed if the refund is later rejected.
- A full refund still revokes everything, through the order reversal, on top of the payment-status check that already denies the download.
To cut off access on a partial refund that does not reach a full line, revoke the grants from the order detail. There is no setting for this; it is a per-order action.
An add-on that issued something per line, such as a license key or a seat, makes the same decision from the onKahunaCartRefundApplied event, whose items carry a line_full flag. See Writing a KahunaCart Payment Provider.