Product releases
This page explains how to publish versioned builds of a downloadable product. It is for the store administrator who sells software, plugins, themes, or anything else that ships a 1.0 and then a 1.1.
A release is a named, dated version of a product that files hang off: "2.4.1, shipped on the 25th, here is what changed". You have always been able to attach files to a product; a release adds the one thing that was missing, which is a place to say which version a file is.
Releases are entirely optional. A store that never cuts one behaves exactly as it always has, and a store that does keeps its existing files exactly as they are.
Before you begin
- A product with
type: digitaland at least one file to publish. See Digital products and downloads for how files, grants and delivery methods work. - The scheduler running, because grants are still minted by the job queue.
Evergreen files and release files
Every downloadable file is now 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 the day you upgrade, no customer's downloads change in any way.
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, because it is two things a customer may be entitled to separately.
Versions, dates and drafts
Releases are listed newest first, ordered by their release date. Version strings are never parsed. That is deliberate: 1.10 sorts before 1.9 in every string comparison and after it in every human's head, and calendar versions, build numbers, 2024.3-beta2 and v3 are all things merchants ship. You state the release date and the store believes you. Two releases stamped the same day fall back to the newer row first.
The release date is a field of its own and not the date the row was created, because the two differ constantly. 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. A version you already used is refused when you create a release by hand, which is what stops a flaky pipeline from leaving you offering "2.4.1" twice.
Published is the draft switch. A release is usually uploaded before it is announced: the pipeline pushes the artifacts, a person flips it live. 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.
- Fill in Version — whatever string you ship under.
- Set Released, or leave it and it stamps now.
- Optionally attach a Build. A release with no files is a perfectly ordinary thing to create first and fill later.
- Write Release notes if you have them. They render on the customer's downloads page as Markdown.
- Leave Published ticked to announce it now, or untick it to stage a draft.
- Add release.
From then on, each version's row expands to show its notes and builds. Upload into this version adds another build to a release you already cut, which is how the second and third platform of a matrix get there by hand. Move an existing file in takes a file you 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 with no entitlement question asked. Detaching is always a widening, so the admin asks you to confirm it.
Publish from CI
One endpoint takes the bytes, the version, the notes and the date together, creates the release if it is new, stores the file, attaches it, and answers with the release and everything on it. It is one call on purpose, because a pipeline is a place where two calls means half a release when the runner dies between them.
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 rather than created and the file is added to it, so a three-runner matrix build posting mac, windows and linux at 2.4.1 produces one release with three files:
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, on the grounds that a pipeline which fat-fingered a date should still publish the build it just made and you can correct the date afterwards.
published defaults to true on this endpoint, because a call named publish that quietly produced a draft would be a trap. A pipeline that stages builds ahead of an announcement sends -F published=false and flips it from the admin when the announcement goes out. Repeating a call never unpublishes a version that is already live, and it replaces the release notes if it carries any — the first runner to finish is not necessarily the one holding them.
The upload goes through exactly the plumbing the product files endpoint uses: the same downloads.max_upload_mb cap, the same extension allowlist, the same server-derived storage path with its random suffix. That last part matters more here than anywhere else, because every version of a product ships a file called the same thing.
The token is an API token with the kahunacart.products.manage permission — the same permission as attaching a file to a product, because that is what this is. 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 two things: the evergreen files in the flat list they have always been in, and below them 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 it always did, with the version noted beside any file that belongs to one.
Release notes go through the same hardened Markdown renderer a product description does, so a <script> typed into a release note renders as the words you typed.
Both pages are ordinary theme templates, and the release markup carries kahunacart-release, kahunacart-release-version, kahunacart-release-date and kahunacart-release-changelog classes for a theme to style. See Theming.
Releases published after somebody bought
Grants are minted per file when an order is paid, so a build uploaded eighteen months after a sale has no grant on the order that bought the product, and no grant means no link.
Publishing therefore walks back over the product's recent orders and mints the grants that are missing. It is idempotent, so publishing the same version twice or flipping a draft back and forth costs a few queries and changes nothing.
Note what that deliberately does: it hands grants to every past buyer, including one who bought three years ago. Whether they may actually use them is a separate question, and it is the one the next section is about.
Who gets which version
Releases are a core KahunaCart feature. Licensing is not required to use them. On a stock store the rule is the simple one: a customer who bought a product may download every published release of it, forever. That is the Gumroad expectation, and it is the right default for the font family, the sample pack, or the ebook that gets a second edition — you bought it, you have it.
It is the wrong default for software sold the way JetBrains sells it: a perpetual licence for everything that shipped inside your updates window, and nothing published after it. Deciding that needs a licence key, a purchase date and a window, none of which the base plugin knows anything about. So rather than guess, KahunaCart asks, every time a customer's releases are about to be listed and again at download time.
The KahunaCart Licenses add-on is what answers. It issues a key per purchase, tracks an updates window against it, and narrows the offering to the versions that shipped inside that window. Install it and your store sells perpetual licences with a support period; leave it out and your store sells versioned downloads that every buyer keeps.
Two properties of that arrangement are worth knowing as a merchant:
- An add-on can only ever narrow the list, never widen it. Nothing can hand a customer a version the store did not already put on the table, and nothing can reach an unpublished draft.
- A broken add-on does not lock a customer out. An answer the store cannot make sense of is treated as no answer at all and the failure is logged. Showing a customer one build too many is a better failure than a support ticket about downloads that vanished.
Evergreen files are never part of this. A file belonging to no release goes to every buyer, full stop.
Entitlement is re-checked at download time
A download URL is a bare token that lives in a customer's email forever. If entitlement were decided only when a page was drawn, a customer whose updates window closed two years ago would keep a working link to every build published since.
So the 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."
That means 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 deliberately 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 there unchanged. It is asked freshly every time.
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, so the builds live where you already put them rather than on a CDN or a release page you maintain separately.
Two public endpoints do the whole update, and both take the customer's licence key as the credential — as ?key= or, better, as an Authorization: Bearer header, because a query string ends up in your access log, in whatever proxy sits in front of it, and in the shell history of whoever ran the command.
| 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. |
A lapsed updates window is an answer rather than a refusal: the key still owns what shipped inside its window, so latest returns the newest build it is entitled to along with updates_until, which is what lets a client show "renew to get 3.0" instead of "update failed". A null release means there is genuinely nothing — a product that never published one, or a window that closed before the first release shipped. Drafts are never listed.
The checksum is what makes the download verifiable: the latest answer is signed and the download stream is not, so a client that trusts the checksum it read there can trust the 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, which is exactly what deleting a product already does. A downloadable is your own build artifact in a private directory, and a cascade quietly shredding a 2 GB build because somebody mis-clicked is not a mistake anything can undo. 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, which would silently hand a version to every buyer of the product. Deleting a release never widens who can download what.
Grants already issued against those files keep their rows, exactly as they do when a single file is deleted. They deny with not_found, which is the truthful answer, and the record of who was given what survives.