Licenses
This page explains how to sell something that has to be unlocked. It is for the store administrator who sells software — an application, a plugin, a theme, a library — and needs each sale to produce a key the customer types in, the software checks, and the store can suspend, extend or revoke afterwards.
Licensing is an add-on, not part of KahunaCart itself. The base plugin knows nothing about keys, seats or updates windows. Install the kahunacart-licenses plugin alongside it and your store starts issuing keys; leave it out and every other part of your store behaves exactly as it does today. Everything on this page needs that plugin, and nothing on this page changes a store that does not have it.
Before you begin
- A working store you can already take an order on. See Getting started.
- The
kahunacart-licensesplugin installed and enabled, and thekahunacart.licensespermission on whoever is going to run it. - The scheduler running, because keys are minted by the job queue when an order is paid rather than during checkout.
- Something to license. A digital product with builds attached is the usual case — see Digital products — but any product can carry a key, including a physical one that ships with a printed certificate.
Licensed products
Nothing earns a key until you say so. Licenses → Licensed products is the list of rules, and a product with no rule sells exactly as it always has.
A rule is addressed by what it governs rather than by an id of its own. A rule with no variant is the product's own and covers every variant of it; a rule naming a variant replaces it for that variant only. That is the same fallback downloadable files already use, and it is what lets you license the "Team" variant of a product on different terms without writing a rule for every other variant beside it.
| Field | What it does |
|---|---|
| Rule kind | license mints keys. renewal extends a key that already exists — see Renewals. |
| Keys per item | How many keys one purchased item earns. Usually one. |
| Seats | How many machines a key may be active on at once. Blank is unlimited. |
| Extra seats per item | Seats added per unit bought, for the customer who puts 5 in the box and expects a five-seat key rather than five keys. |
| Term (days) | How long the licence itself lasts. Blank is perpetual. |
| Updates window (days) | How long the purchase keeps earning new builds. Blank is forever. |
| Key prefix | Up to eight letters or digits marking the keys this product issues. |
Every field left blank falls back to the store defaults in Licenses → Settings, which is also where you switch licensing on at all and choose whether keys are minted when payment is received or when the order is completed. Underneath the terms, each rule carries its own delivery instructions and its own licence files, both covered in What the customer gets.
Term and updates window are two separate axes
The term is how long the licence lives. The updates window is how long it keeps earning new builds. They are independent dates on the same key, and keeping them apart is the whole reason this add-on exists.
A key whose updates window has lapsed still reports active and the software still runs — it has simply stopped being offered versions published after the window closed. A key whose term has run out reports expired, and that is the one that stops the software. Set a term and no updates window for a subscription-shaped product that dies when it is not paid for. Set an updates window and no term for the other bargain.
That other bargain — a perpetual licence with a year of updates — is how JetBrains sells, and it is the headline case this add-on was built for. You are not enforcing it by hand: KahunaCart asks the licensing plugin every time a customer's versions are about to be listed and again at download time, and the answer narrows the list to the builds that shipped inside that key's window. The boundary is inclusive and is compared against the release date rather than today, so a build published on the last day of the window stays downloadable forever and one published the day after never becomes downloadable at all. Product releases explains publishing a version and who gets which one; this page does not repeat it.
Note
Releases are a core KahunaCart feature and work without licensing. On a stock store every buyer gets every published version, forever. Licensing is what narrows that, and an add-on can only ever narrow it — nothing here can hand a customer a version the store did not already publish.
What a key looks like
The shipped default is KC-XXXX-XXXX-XXXX-XXXX: a prefix, then four blocks of four characters drawn from an alphabet with 0 O 1 I L left out, so a key read off a screen cannot be mistyped into a different valid key. Four blocks of four is about 79 bits of randomness, which is well past the point where guessing is an attack anybody would bother with.
Six fields under Settings → Key format describe it, with a live example key beside them that updates as you type:
| Field | What it does |
|---|---|
| Prefix | The leading marker on every key — up to eight letters or digits, empty for none. |
| Blocks | How many groups the random part prints in, 1–8. |
| Characters per block | How many characters each group holds, 2–12. |
| Separator | What goes between the groups: up to three punctuation characters, or nothing at all for one unbroken run. Letters and digits are refused because they would become part of the key. |
| Alphabet | The characters keys are drawn from, at least eight distinct. |
| Template | The advanced layout, e.g. {prefix}/{block:5}-{block:5}-{block:5}. When it is set it replaces the three fields above entirely. |
A rule can override any of the six for its own product, from the collapsed Key format group in the rule editor. It sits on "Use the store format", which is the right answer for almost every product; switch it to "Custom for this product" and anything you leave blank is still inherited, so a rule that only wants a different separator says only that.
A key's identity is its letters and digits, upper-cased, and nothing else. Two things follow, and they are what makes the format safe to change:
- Spacing, case and separators never matter when a customer types a key back.
KC-2345-6789-ABCD-EFGH,kc 2345 6789 abcd efghandkc23456789abcdefghare one key, and so is the same string with the dashes turned into slashes by a mail client. The customer does not have to reproduce the punctuation you sent them. - Changing the format only affects keys issued from then on. Existing keys keep working and keep printing the way the customer already has them written down, because their identity never involved the layout.
What the customer gets
Four places, all of them switched on by the plugin being enabled — a store that issues keys and does not tell the customer what they are has not issued anything.
The receipt. The order page grows a Your License Keys panel above every other panel, because the key is what the customer came back for. Each key is a card carrying the product and variant, a status pill, the key itself in a monospace box with a copy button, seats used against seats allowed, the expiry and the end of the updates window, your instructions, and a download link per licence file. Between paying and the queue draining, the panel says the keys are on their way and how many are coming rather than showing an empty box.
The order email. The same keys go into the order confirmation, as a separate plain-styled partial with everything inline and no copy button, because there is no stylesheet and no clipboard in a mail client. The key is in the first email rather than the second: issuing runs ahead of the confirmation, and where it somehow has not finished the message is held a few seconds at a time rather than going out empty.
The Licenses tab. {base}/account/licenses lists every key a signed-in customer holds, newest first, with the part a receipt cannot have — where the key is in use. Each live activation is a row with the machine's label, when it was activated, when it was last seen, and a Deactivate button, so a customer whose laptop died gets their seat back without opening a ticket. A one-line card on the account overview counts their licences, the ones expiring within thirty days, and the ones already gone.
Licence files. A rule can generate files a customer downloads per key — a .lic, a certificate, a config fragment. They are generated on demand from a template rather than stored, so there is nothing on disk to back up, nothing to go stale when a key is extended, and nothing to leak. Put {signature} in one and the file carries an HMAC your software can check on a machine that has never reached the store, which is what an air-gapped install or a first run before the network is up has to work from.
Delivery copy
Four pieces of merchant-written text travel with a key: the instructions printed beside every key, an email introduction printed once above the keys in an order email, an account page introduction at the top of the Licenses tab, and the licence files themselves. All four are Markdown, all four take placeholders, and all four resolve in two layers — the store default from Settings → What the customer sees, replaced field by field by a product's own rule. Field by field matters: a product that only wants its own instructions should not lose the store's email introduction as a side effect.
The placeholders are {key}, {product}, {variant}, {customer_name}, {customer_email}, {order_number}, {expires}, {updates_until}, {seats}, {store_url}, {account_url}, {file_url:0} for the first licence file, and {signature}. The admin prints the legend beside every box and drops a token into whichever field you were last typing in when you click it.
A placeholder nobody recognises is left exactly as written. Somebody who types {licence} sees {licence} on the page and fixes it, where deleting it silently would leave a sentence with a hole in it and nothing to explain the hole.
The two introductions are the one exception to all of this: they print once above every key on the order, so the key-specific tokens are left as typed there and only {customer_name}, {customer_email}, {order_number}, {store_url} and {account_url} resolve. Put the key in the instructions, which print per key.
Every panel above is a Twig partial you can replace by copying it into your theme, and every colour it uses reads a token the base plugin already publishes — so a theme that has already been styled for KahunaCart styles the licence panels for nothing. See Theming.
The admin
Licensing appears inside KahunaCart's own admin under Add-ons in the side navigation, at /admin/plugin/kahunacart#/section/licenses, with three tabs.
Licenses is the list. Search matches a fragment of a key, a buyer's email address or an order number — the three things a support conversation ever starts with — and the status chips narrow it to active, suspended, revoked or expired. Each row carries the key, its product, seats in use against seats allowed, the customer, the order and the two dates, with Copy to put the key on the clipboard and Open for the detail screen.
The detail screen is everything one key knows about itself: the facts, an Edit block for the seat allowance, the expiry, the updates window, a private note the customer never sees and a JSON metadata object handed to your software with every check; an Activations table with Free seat beside each machine; and What the customer sees, which renders your delivery copy with this key's own values so you can read the email before a customer does. The actions are Suspend (reversible, and it leaves activations in place so reinstating puts the customer back exactly where they were), Reinstate, Revoke (permanent, and it asks for a reason that stays on the record), and Regenerate, which revokes the current key and mints a replacement carrying the same order, customer, product, seats and dates.
Issue a key mints one with no order behind it — a comp, a review copy, a replacement for somebody who bought through another channel. Choose the product, optionally name a customer by email, override the seats, term or updates window, and anything left blank takes the store default. No order is invented to hang it on, because that would put revenue in your reports that nobody paid.
Licensed products is the rule editor covered above. Settings holds the store-wide defaults, the key format, the public API's switches and rate limits, the delivery copy and the signing secret.
Note
Everything here is gated on the single permission kahunacart.licenses, and there is deliberately no view/manage split. A licence key is the credential, so reading the list is already handing out the goods and a look-but-do-not-touch permission would be theatre with a maintenance cost. Anyone who may look may also revoke.
Warning
The signing secret is written once and shown once, and afterwards only ever reported as its last four characters. Rotate is the only way it ever changes, and rotating it breaks every client still pinned to the old one and invalidates every licence file already downloaded.
Renewals
A renewal is a product that sells more time on a key the customer already holds. They buy it like anything else in your catalog — it has a price, a variant, a place in a category, a tax class — and the money lands on an ordinary order you report on, refund and reconcile like every other order in the store. Nothing new is minted; the key they typed into the buy form gets its dates pushed out.
Setting one up is two products and one rule. Keep the licensed product exactly as it is, add a second product to your catalog — "Widget Pro, one more year" — and give that product a rule whose Rule kind is renewal, whose Renews which product names the licensed one, and whose term and updates window say how much time the money buys. Leave the product unset and the renewal accepts any live key in the store, which is occasionally what a one-product merchant wants and is usually a mistake.
Sell several lengths either by putting them on one product as variants with a rule on each, or by doing nothing at all: buying three of a one-year renewal buys three years, because there is only one key to extend and a customer who put 3 in the box has to get three of something.
The renewal product's page grows a License key to renew field inside the ordinary add-to-cart form. Signed-in customers get a dropdown of their own eligible keys with a last option that opens a text box, which is how somebody renews on a colleague's behalf; everybody else types the key. It is checked at add-to-cart, before any money moves, and the customer is told at the field whether the key is unrecognised, for a different product, revoked or suspended. A key that has already run out is accepted, deliberately — it is the single most common reason anybody buys a renewal, and refusing it would leave only the customers who did not need one able to buy one.
When payment lands, three things are worth knowing:
- Time is added rather than replaced. The new dates run from
max(now, where the key currently stands)plus the days bought, so renewing early costs the customer nothing and renewing two years late starts from today rather than from a date that has already gone by. - The old dates are written down on the order. That record is what makes a renewal reversible and what stops a queue retry from adding the year twice.
- A refund puts the time back exactly where it was, to the dates that were recorded rather than to "a year before now" — those are not the same arithmetic, and only the values the key actually had are the right ones to return to. Reversing an order revokes the keys it minted and restores the ones it renewed, which are different actions on purpose: the renewed key belongs to an earlier order that is still perfectly good. A renewal somebody has already built on — a second renewal landed since, or you edited the dates by hand — is marked superseded and left for a person to judge rather than silently thrown away.
Every key in the customer's account grows a Renew link as soon as a renewal rule exists for its product, pointing at that renewal product's page. It is computed from the rules rather than configured, so writing the rule is the whole setup, and it reads Renew now once the key has lapsed. Revoked keys never show it, and receipts never carry it — a customer looking at a key they bought thirty seconds ago is not being sold more time on it.
Note
This is the manual kind of renewal: the customer comes back, buys, and the key gets longer. Automatic renewal — a card charged on a schedule — is a subscription, and subscriptions are a KahunaCart feature rather than a licensing one. When KahunaCart grows them, a renewal rule is what a subscription renewal will run through.
The public API
Seven unauthenticated endpoints answer software running on your customer's own machine. They live under the Grav API plugin's base, /api/v1 in a stock install, and the licence key is the credential — there is no login, and there never will be one.
Send the key as an Authorization: Bearer header wherever you have the choice. A query string lands in your access log, in whatever proxy sits in front of it, and in the shell history of whoever ran the command; the header does not.
Three endpoints cover the software you sell:
POST /validate— is this key good? Answers the status, the product, the seats, the expiry, the end of the updates window, and whatever metadata you put on the key.POST /activate— take a seat for a named installation. Re-activating a machine that already holds one is a success that costs nothing, because software checks in on every launch and a reinstall must not cost a second seat.POST /deactivate— give the seat back. Releasing a seat that is already free is a success too, because an uninstaller retrying after a dropped connection has to be told the seat is free.
curl -sS https://your-store.example.com/api/v1/kahunacart/licenses/public/validate \
-H 'Content-Type: application/json' \
-d '{"key":"KC-2345-6789-ABCD-EFGH","instance":"studio-01","product":"pro-edition"}'
{
"data": {
"key": "KC-2345-6789-ABCD-EFGH",
"valid": true,
"status": "active",
"product": { "id": 7, "slug": "pro-edition", "title": "Pro Edition" },
"expires_at": null,
"updates_until": 1791536000,
"seats": { "max": 2, "used": 1 },
"activated": true
}
}
Suspended, revoked and expired keys are answers, not errors. They come back 200 with valid: false and a status, because your software has to tell the customer something different in each case and an error code would flatten all three into "check failed".
Two more endpoints turn your store into the update server for the builds you already publish there. GET /latest answers the newest release the key may have — version, date, notes, and a download URL and sha256: checksum per file — and GET /release sends the bytes of one of them. A lapsed updates window is an answer rather than a refusal here: the key still owns what shipped inside its window, so latest returns the newest build it is entitled to along with the date the window closed, which is what lets your client say "renew to get 3.0" instead of "update failed". Product releases covers that flow from the merchant's side, including publishing a build from CI in a single call.
All seven public endpoints
| Method | Path | What it answers |
|---|---|---|
POST |
/kahunacart/licenses/public/validate |
Is this key good, and what does it entitle. |
POST |
/kahunacart/licenses/public/activate |
Take a seat for a named installation. |
POST |
/kahunacart/licenses/public/deactivate |
Give that seat back. Idempotent. |
GET |
/kahunacart/licenses/public/updates |
Does this key cover a release put out on a given date. |
GET |
/kahunacart/licenses/public/file |
The licence file at position n, generated on demand. |
GET |
/kahunacart/licenses/public/latest |
The newest store-hosted release this key may have, with checksums. |
GET |
/kahunacart/licenses/public/release |
The bytes of one file from that answer. |
Refusals carry a stable machine code in the body and in an X-KahunaCart-Error header — license_not_found, product_mismatch, seats_full, rate_limited and a handful more. Branch on the code; the message beside it is written for people and may be reworded at any time.
Three switches under Settings → Public validation API govern all of it: whether the API is on at all, how many requests a minute one key and one address may make, and which browser origins may call it — leave that empty and only server-side clients get through. Turning the API off also turns your licence file links into 404s, so a store that is not running it should put what the customer needs in the instructions instead.
Set a signing secret and every response is signed, which is what lets a client trust an answer it received over a connection it does not fully control, and what lets {signature} in a licence file be verified on a machine that has never reached the store. Downloads themselves are not signed — the checksum in the latest answer is what makes them verifiable, so a client that trusts the checksum can trust the bytes that match it.