License Server v1.25.0 · released 28 Oct 2026release notes →

Limit a license key to a set number of computers

Last updated October 27, 2026

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_id is 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_id of their choosing, and can keep a machine's seat alive indefinitely by calling validate-license with 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

More in Guides, or the full documentation.