Limit a license key to a set number of computers
You sell a key. The customer installs it on their desktop, their laptop, and their colleague's machine, and then their colleague's colleague's machine. Nothing stops them, because a key is just a string.
This guide caps that with Casazium License Server — hosted, or self-hosted. A license gets a number of seats, each machine takes one when it activates, and the next machine is refused.
Set the seat count with max_activations
max_activations is how many machines may hold the key at once. Leave
it out and the license works on one machine. Three seats covers a
desktop, a laptop and a replacement.
Selling through a storefront? Set seats on the mapping instead
If the license comes from Stripe, Lemon Squeezy or Paddle, read Storefront licenses below before changing seats here — a subscription will overwrite it.
In the console
On a hosted account, or in the self-hosted bundle's console:
- A new license: Licenses → Issue license, and set Max activations. It defaults to 1.
- A license you've already issued: open it from Licenses, choose Edit terms, change Max activations and choose Save. There's no need to re-issue the key.
Using the Console covers the rest of the issue form.
Through the API
Where these calls go. Self-hosted, call your own server with its
admin API key; https://your-license-server.example.com stands in for
your address (the self-hosted bundle answers on
http://localhost:3001 on the machine running it). On a hosted
account, send them to https://license-cloud-api.casazium.com — not
license-cloud.casazium.com, which is the console — and use your API
key from Settings → API access. A hosted account never accepts
ADMIN_API_KEY.
curl -X POST https://your-license-server.example.com/v1/issue-license \
-H "Authorization: Bearer $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"tier": "pro",
"product_id": "my-product",
"issued_to": "customer@example.com",
"expires_at": "2027-12-31T23:59:59.000Z",
"max_activations": 3
}'
tier, product_id and issued_to are required — product_id is your
product in License Server, which you choose and reuse. Omit expires_at
for a license that never expires.
Change the count later without re-issuing:
curl -X POST https://your-license-server.example.com/v1/admin/update-license-terms \
-H "Authorization: Bearer $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"max_activations": 5
}'
Lowering the count
Lowering it, in the console or through the API, deactivates nothing by itself: a license already over its new number keeps its machines and refuses the next one. Unless the license has a lease — an optional setting (Free inactive machines in the console) that lets a seat be taken back from a machine that has stopped checking in. Then the next activation removes as many inactive machines as it needs to get under the new cap. See Reclaim license seats from machines that are gone.
max_activations: null removes the cap entirely
null means unlimited machines, not one — the seat check is skipped
altogether, and a lease on such a license never reclaims anything.
In the console it's the Unlimited seats checkbox in Edit terms,
which hides the Max activations field. issue-license rejects null;
through the API you reach it only with update-license-terms.
Storefront licenses overwrite what you set here
A license issued by Stripe, Lemon Squeezy or Paddle takes its terms from
the mapping, and a subscription re-applies them. A plan change, a
seat change on a per-seat plan, or Apply to existing subscribers…
in the console overwrites tier, limits, max_activations and
lease_seconds on that license with the mapping's.
A mapping with no seat count set is not neutral — it resets the license to one machine. Raise a subscriber to 5 seats by hand, and their next plan change silently cuts them to 1, with nobody touching anything. Set the seats on the mapping, not on the license — for Stripe, on both the Payment Link mapping and the price mapping.
For one-time purchases, Edit terms (or update-license-terms)
fixes licenses already issued, but every future purchase still gets the
mapping's count. Mappings can't be edited: delete it, recreate it with the right
seats, then patch the ones already sold. The
Stripe,
Lemon Squeezy and
Paddle guides cover which mapping and
when it applies.
Each machine takes a seat by activating
Activation needs the key, plus something that identifies this machine.
npm install @casazium/license-sdk
The code below uses validate() and err.status, both added in SDK
0.4.0.
import { CasaziumLicenseClient } from '@casazium/license-sdk';
import { getFingerprint } from '@casazium/license-sdk/fingerprint';
const client = new CasaziumLicenseClient({
// Including /v1. The SDK appends the route to this verbatim.
baseUrl: process.env.LICENSE_SERVER_URL ?? 'https://your-license-server.example.com/v1',
});
// Your product's UUID: in the response to your first issue-license
// call, and on the license's page in the console. It never changes.
const MY_PRODUCT_UUID = 'your-product-uuid';
const { key: licenseKey } = await loadLicense(); // your own storage
const instanceId = getFingerprint(); // Node only; see below
let token = await loadStoredToken();
try {
// Is this key for THIS product? activate-license doesn't check:
// without this, your app accepts any key you've ever issued,
// including one for a cheaper product.
await client.verifyKey(licenseKey, MY_PRODUCT_UUID);
const activation = await client.activate(licenseKey, instanceId);
// `token` comes back only on the call that CREATES the activation. A
// repeat of the same key/instance_id returns
// { activated: true, alreadyActivated: true } with no token, so don't
// overwrite a stored one with undefined.
token = activation.token ?? token;
await saveToken(token); // OS keychain, not localStorage
} catch (err) {
handleActivationError(err); // next section
}
Verify the key is for this product before activating.
activate-license doesn't check, so without the verifyKey call above
your app accepts any key you've ever issued — including one for a
cheaper product.
The same activation over plain HTTP (sent to the address in Through the API):
curl -X POST https://your-license-server.example.com/v1/activate-license \
-H "Content-Type: application/json" \
-d '{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"instance_id": "a-stable-id-for-this-machine"
}'
No auth header — activation is the customer's call, not yours. The first
activation of a key/instance_id pair answers with the token; a repeat of
the same pair answers {"activated": true, "alreadyActivated": true}
and no token. (alreadyActivated is camelCase; the request fields are
snake_case.)
You don't need to suppress retries. The SDK never retries activation
— the one call where a lost response costs you the token — and retries
only network errors and 5xx elsewhere, never a 429. If you're calling
over plain HTTP, don't retry activation yourself: if the server saved it
and the response was lost, the retry returns alreadyActivated and no
token, and a token is never issued twice.
Make instance_id stable across restarts, updates and reinstalls, or
the same computer takes a second seat. getFingerprint() is Node-only
and synchronous; the
instance ID recipes
cover other platforms. For a server or container, derive it once and
persist it in a volume or config file — a value computed at runtime
makes every redeploy a new machine, and the customer hits the cap on
their third deploy. See
Licensing server software.
Activate once, then validate. activate-license and
deactivate-license each carry a limit of 20 requests per 15 minutes
per license key, shared across every machine on it — a bulk rollout
or a re-running test harness hits this first. (Both also sit under a
300-per-15-minutes limit per IP address.) validate-license doesn't
carry the per-key limit, so make it your launch check:
const { valid } = await client.validate(licenseKey, instanceId);
if (!valid) {
// This machine no longer holds a seat: the customer freed it, you
// freed it, or - if you set a lease - it was freed automatically
// (off by default). It can also mean the license was revoked, has
// expired, or is unknown, so don't assume re-activating will work.
promptToActivateAgain();
}
Over HTTP it's POST /v1/validate-license, with the same two fields.
Let customers move their own machine
POST /v1/deactivate-license
takes the key and that stored token — no admin key — and frees that one
seat. Put it behind a visible "deactivate this computer" control and
most seat email stops arriving. If a customer's app has lost its token,
or the machine is simply gone, see
Reclaim license seats.
What the customer sees when they run out
The activation that doesn't fit is refused with
403 {"error": "Activation limit exceeded for this license key"}.
To see which machines are holding the seats, open the license in the
console: its Activations table lists each machine, with
Deactivate on each row. Through the API, call
GET /v1/list-activations/{key} with your admin key. See
Reclaim license seats for both.
The SDK throws on a refusal rather than returning a flag. A 403 can
mean revoked, expired or no seats — and verifyKey in the same
block answers 404 for an unknown key and 403 for an inactive one — so
the status alone can't tell the customer what to do:
function handleActivationError(err) {
// No answer at all - a network error or timeout has no status.
// verifyKey throws its own status-less error for a product mismatch.
if (err.status === undefined) {
if (/product_uuid mismatch/.test(err.message)) {
return showMessage('This key is for a different product.');
}
throw err; // A network error is not an invalid license.
}
if (err.status === 429 || err.status >= 500) throw err; // Also no answer.
if (err.status === 400) throw err; // Your bug, not theirs.
// A 404 ending ": Not Found" is a route the server doesn't have: a setup
// problem (wrong base URL, or a server too old), not the buyer's license.
if (err.status === 404 && /: Not Found$/.test(err.message)) throw err;
if (err.message.includes('Activation limit exceeded')) {
showMessage('This license is in use on the maximum number of computers.');
} else {
// Revoked, expired, or unknown: a billing problem, not a seat one.
// Don't show err.message - it reads "activate failed with 403: ...".
showMessage('There is a problem with this license. Please contact us.');
}
}
A throw here means you got no licensing verdict — a network failure,
an overloaded server, or a malformed request or setup problem of your
own. Don't fail the customer's launch on bad hotel wifi: keep them
running on your last good check, as
the desktop guide shows.
Let customers free their own seats
The end-user license portal
gives each customer a page where they can deactivate a machine
themselves, which is the cheapest version of the deactivate button
above. It's controlled per deployment by PORTAL_ENABLED — off by
default self-hosted, and on hosted accounts Casazium runs it. Each
license's portal link is shown once, when you issue it: the console's
Portal link, or the portal_token the API returns. If you didn't
keep it, choose Reissue portal link on the license's page, or call
POST /v1/admin/reissue-portal-token.
What this does and doesn't stop
A seat cap is a deterrent against casual sharing, not a lock:
instance_idis a string your app chooses. A customer who wants to can send a fresh one per machine, and each looks like a new computer until the cap is reached.- The license key isn't a secret — it's meant to live in a distributed
app. Anyone holding it can activate with an
instance_idof their choosing, and can keep a machine's seat alive indefinitely by callingvalidate-licensewith it. - Anyone who can modify your binary can remove the check.
What it does do is make sharing deliberate rather than accidental: the colleague who would have borrowed a key gets a clear refusal, and the honest customer who needs a fourth machine asks you instead. That covers most of what happens in practice, and offline verification with signed licenses raises the cost of the rest.
Where to go next
- Reclaim license seats from machines that are gone — free a stuck seat today, or set a lease so they free themselves.
- License Activation — instance IDs per platform, and the full activation lifecycle.
- Licensing an Electron or Tauri desktop app — where the launch check fits in a real app.
- Licensing server software — instance IDs that survive a redeploy.
More in Guides, or the full documentation.