Skip to content

Powered by Grav CMS + Helios Theme

Product releases

This page explains how to publish versioned builds of a downloadable product. It is for a store that sells software, plugins, themes, or anything else that ships a 1.0 and then a 1.1.

A release groups files under a product version with a release date and notes: "2.4.1, shipped on the 25th, here is what changed". Attaching a file to a release identifies which version it belongs to.

Releases are optional. A store that never cuts one behaves as before, and a store that does keeps its existing files as they are.

Before you begin

  • A product with type: digital and at least one file to publish. See Digital products and downloads for how files, grants and delivery methods work.
  • The scheduler running. Grants are still minted by the job queue.

Evergreen files and release files

Every downloadable file is one of two things.

Evergreen file Release file
Belongs to a release no yes, exactly one
Who gets it every buyer of the product every buyer entitled to that version
Typical thing the manual, the licence PDF, a sample pack, a font family a build: app-2.4.1-macos.zip
On the account page one flat list, as before grouped under a version heading with its date and notes

Every file that already exists is evergreen and stays evergreen. The migration that adds releases backfills nothing, so no customer's downloads change when you upgrade.

A release holds as many files as it needs. A desktop application ships a mac build, a windows build and a linux build at the same version, and all three belong to 2.4.1 together.

A file belongs to at most one release. A build that ships in two versions is two uploads.

Versions, dates and drafts

Releases are listed newest first by release date. Version strings are never parsed. 1.10 sorts before 1.9 as a string, and calendar versions, build numbers, 2024.3-beta2 and v3 are all things stores ship. The store uses the release date you enter. Two releases stamped the same day list the newer row first.

The release date is its own field, not the date the row was created. Importing five years of history creates every row today, and a CI job publishing a build tagged last Friday should say last Friday.

A version is unique per product. Creating a release by hand with a version you already used is refused.

Published is the draft switch. An unpublished release is never listed and never downloadable, whatever else is true of the customer.

Publish from the admin

Open Products → a product → Releases. The panel sits alongside Files and shows every version you have cut, drafts included, with the product's evergreen files listed separately.

  1. Fill in Version with whatever string you ship under.
  2. Set Released, or leave it to stamp now.
  3. Optionally attach a Build. You can create a release with no files and fill it later.
  4. Write Release notes if you have them. They render on the customer's downloads page as Markdown.
  5. Leave Published ticked to announce it now, or untick it to stage a draft.
  6. Click Add release.

Each version's row then expands to show its notes and builds. Upload into this version adds another build to a release you already cut. Move an existing file in takes a file already attached to the product and files it under this version instead.

Warning

Taking a file back out of a release makes it evergreen again, which hands it to every buyer of the product. The admin asks you to confirm.

Publish from CI

One endpoint takes the bytes, the version, the notes and the date together. It creates the release if it is new, stores the file, attaches it, and answers with the release and everything on it.

BASH
curl -X POST https://your-site.com/api/v1/kahunacart/products/12/releases/publish \
  -H "Authorization: Bearer $KAHUNACART_TOKEN" \
  -F file=@dist/widget-2.4.1-macos.zip \
  -F version=2.4.1 \
  -F released_at=2026-08-25 \
  -F "changelog=<CHANGELOG-2.4.1.md"

Repeating a version here is not an error. The release is found instead of created and the file is added to it. A three-runner matrix build posting mac, windows and linux at 2.4.1 produces one release with three files:

BASH
for build in dist/*.zip; do
  curl -X POST "https://your-site.com/api/v1/kahunacart/products/12/releases/publish" \
    -H "Authorization: Bearer $KAHUNACART_TOKEN" \
    -F "file=@${build}" -F version=2.4.1
done

released_at accepts a unix timestamp or anything PHP's strtotime() understands, so 2026-08-25 and last friday both work. A date it cannot read means now; correct it afterwards from the admin.

published defaults to true on this endpoint. A pipeline that stages builds ahead of an announcement sends -F published=false and flips it from the admin later. Repeating a call never unpublishes a version that is already live, and it replaces the release notes if it carries any.

The upload uses the same plumbing as the product files endpoint: the same downloads.max_upload_mb cap, the same extension allowlist, the same server-derived storage path with its random suffix. Every version of a product ships a file with the same name, so the suffix matters here.

The token is an API token with the kahunacart.products.manage permission, the same permission as attaching a file to a product. Every release route is nested under its product and asks for it.

Every release route
Method Path What it does
GET /kahunacart/products/{id}/releases Every release of the product, newest first, drafts included, each with its files. Also returns the product's evergreen files.
POST /kahunacart/products/{id}/releases Create a release. version is required; released_at, changelog and published are optional. A duplicate version is a 409.
POST /kahunacart/products/{id}/releases/publish The one-shot CI call. A repeated version adds a file rather than failing.
PATCH /kahunacart/products/{id}/releases/{releaseId} Edit version, released_at, changelog or published. Only the fields the body names are written.
DELETE /kahunacart/products/{id}/releases/{releaseId} Delete the release and its files' rows.
POST /kahunacart/products/{id}/releases/{releaseId}/files/{fileId} Move an already uploaded file into this release.
DELETE /kahunacart/products/{id}/releases/{releaseId}/files/{fileId} Take the file back out, making it evergreen again.

Responses are 413 for a file over the cap, 422 for a missing file, a missing version or a refused extension, 404 for a product that is not there, and 409 only on POST /releases.

What the customer sees

The account downloads page shows the evergreen files in a flat list, then one block per version: heading, release date, notes rendered as Markdown, and every build published under it, newest version first. The order page lists the same grants as before, with the version noted beside any file that belongs to one.

Release notes go through the same hardened Markdown renderer as a product description, so a <script> typed into a release note renders as plain text.

Both pages are ordinary theme templates. The release markup carries kahunacart-release, kahunacart-release-version, kahunacart-release-date and kahunacart-release-changelog classes for a theme to style. See Theming.

On a theme's own pages

A home page's release ticker, a product's "what's new" or a changelog page can list releases without the buyer signing in, with kahunacart_releases(). It answers the facts and nothing a buyer would be given: the version, the date, the notes, and for each file its label, name and size. No download link or token is in it, because a file is the buyer's. See kahunacart_releases(product, limit).

Releases published after somebody bought

Grants are minted per file when an order is paid. A build uploaded eighteen months after a sale has no grant on that order, and no grant means no link.

So publishing walks back over the product's recent orders and mints the missing grants. It is idempotent: publishing the same version twice or flipping a draft back and forth changes nothing.

This creates grants for every past buyer, including one who bought three years ago. The next section explains which buyers are entitled to use them.

Who gets which version

Releases are a core KahunaCart feature. Licensing is not required to use them. On a stock store, a customer who bought a product may download every published release of it, forever. That is the right default for a font family, a sample pack, or an ebook that gets a second edition.

It is the wrong default for software sold with an updates window: a perpetual licence for everything that shipped inside the window, and nothing after it. Deciding that needs a licence key, a purchase date and a window, which the base plugin does not have. So KahunaCart asks an add-on, every time a customer's releases are about to be listed and again at download time.

The KahunaCart Licenses add-on answers. It issues a key per purchase, tracks an updates window against it, and narrows the list to the versions that shipped inside that window. Install it and your store sells perpetual licences with a support period; leave it out and every buyer keeps every version.

Two rules matter to you as a merchant:

  • An add-on can only narrow the list, never widen it. Nothing can hand a customer a version the store did not already offer, and nothing can reach an unpublished draft.
  • A broken add-on does not lock a customer out. An answer the store cannot read is treated as no answer, and the failure is logged.

Evergreen files are never part of this. A file belonging to no release goes to every buyer.

Entitlement is re-checked at download time

A download URL is a bare token that lives in a customer's email forever. So the entitlement check runs again, for the single version the requested file belongs to, before the download counter is spent. A refusal costs the customer nothing. A version they are not entitled to is refused with "This version is not included with your purchase."

All of the following take effect immediately, on links that already exist:

  • unpublishing a release
  • deleting a release
  • a licence lapsing

Grants themselves are untouched by any of this. Entitlement is not frozen onto a grant the way download limits and expiry are, because a licence can lapse, be renewed, and lapse again while the grant row sits unchanged.

The update API for software sellers

If you sell software that checks for its own updates, the Licenses add-on turns your store into the update server. The builds live where you already put them.

Two public endpoints do the whole update. Both take the customer's licence key as the credential, as ?key= or, better, as an Authorization: Bearer header. A query string ends up in access logs, proxies and shell history.

Endpoint Answers
GET {base}/kahunacart/licenses/public/latest The newest release this key may have: version, date, notes, and a download URL and sha256: checksum per file.
GET {base}/kahunacart/licenses/public/release The bytes of one file from that answer.

An expired updates window does not cause an error. The key remains entitled to releases published during its updates window, so latest returns the newest build it is entitled to along with updates_until. A client can then show "renew to get 3.0" instead of "update failed".

A null release means there is nothing: a product that never published one, or a window that closed before the first release shipped. Drafts are never listed.

The checksum makes the download verifiable. The latest answer is signed and the download stream is not, so a client that trusts the checksum can trust bytes that match it. Files over the store's checksum cap answer null, which means "cannot verify", not "verification failed".

Full details, the other five endpoints, seats, terms and renewals are on the Licenses page.

Delete a release

Deleting a release removes its file rows and leaves the bytes on disk, the same as deleting a product does. Removing bytes is always an explicit per-file delete through the product's Files panel.

Deleting a release does not turn its builds into evergreen files. Deleting a release never widens who can download what.

Grants already issued against those files keep their rows, as they do when a single file is deleted. They deny with not_found, and the record of who was given what survives.