Skip to content
License Server v1.29.0 · released 30 Oct 2026release notes →
Skip to content

License Activation ​

Comprehensive guide to implementing license activation and instance tracking in your applications.

Overview ​

License activation allows you to track which instances are using a license, enforce activation limits, and provide better license management for your customers.

Your app activates; you see the result in the console. A license's page lists the machines holding its seats under Activations, where you can free one.

Your API address

The examples use https://your-license-api.com as a stand-in for your License Server's API address. Put yours in its place:

  • Hosted account: https://license-cloud-api.casazium.com. The console's Settings → API access → API base URL shows it with /v1 already on the end, https://license-cloud-api.casazium.com/v1, so that value replaces https://your-license-api.com/v1 as a whole: don't add a second /v1. Not license-cloud.casazium.com: that's the console, and an API call sent there gets a JSON 404 naming the right address. Where an example sends $ADMIN_API_KEY, use your account's API key from the same Settings → API access section instead: a hosted account doesn't accept ADMIN_API_KEY.
  • Self-hosted: your own server's address. The self-hosted bundle answers on http://localhost:3001 on the machine running it, so http://localhost:3001/v1 replaces https://your-license-api.com/v1.

With the SDK ​

@casazium/license-sdk makes these calls for a JavaScript app. Setting it up is in Using the SDK; it's an ES module, so run these snippets as one (a .mjs file, or "type": "module" in your package.json).

js
import { CasaziumLicenseClient } from '@casazium/license-sdk';
import { getFingerprint } from '@casazium/license-sdk/fingerprint';

const client = new CasaziumLicenseClient({
  baseUrl: 'https://your-license-api.com/v1',
});

const key = 'the-customers-license-key';
const instanceId = getFingerprint(); // the same value on every launch

// Takes a seat on this machine.
const { alreadyActivated, token } = await client.activate(key, instanceId);
if (token) {
  // Store it with the instance ID. It's returned only on the call that
  // creates the activation, and deactivate() and trackUsage() need it.
} else if (alreadyActivated) {
  // This machine already held a seat: use the token you stored when it
  // first activated. Don't overwrite it.
}
  • getFingerprint() returns this machine's ID (from node-machine-id), so a machine that activates again on every launch keeps the one seat. It runs in Node, including Electron's main process, not in a browser. Instance ID Generation covers other ways to make an ID.
  • activate(key, instanceId) resolves { activated: true, token } the first time, and { activated: true, alreadyActivated: true } with no token when this machine already holds a seat. It throws when the server refuses, for example activate failed with 403: Activation limit exceeded for this license key, with the HTTP status on err.status.
  • The SDK never retries activate(). If it fails with a network error, the activation may still have been saved, and calling it again returns alreadyActivated with no token. A lost token can be replaced only by you: Reissue token on the license's page in the console (see Activations), or POST /v1/admin/reissue-token.

At each launch, check that this machine still holds its seat. A customer can free it from the license portal, or you from the console, and verifyKey() alone keeps passing after that:

js
const { valid } = await client.validate(key, instanceId);
if (!valid) {
  // The seat was freed, or the license is no longer good:
  // ask the customer to activate this machine again.
}

validate() needs SDK 0.4.0 or later. It answers only { valid }, with no reason. It's also the check-in that keeps a machine's seat when the license has a lease: see What counts as a check-in.

To give the seat back, for example when the customer signs out or uninstalls, deactivate with the stored token:

js
const { deactivated } = await client.deactivate(key, token);
// false, not an error, when no activation matched the token:
// the seat was already freed.

The SDK doesn't cover freeing another machine's seat. That needs your admin credentials, so it isn't something a customer's app does: use Deactivate on the license's Activations in the console, or the API (see Handling Activation Limits).

Without the SDK (curl or any language) ​

The rest of this section is the API the SDK calls, for an app in another language, or JavaScript without the SDK. Client Examples has complete Python, Java and Go clients.

Basic Activation ​

Simple Activation ​

Activate a license for a specific instance:

bash
curl -X POST https://your-license-api.com/v1/activate-license \
  -H "Content-Type: application/json" \
  -d '{
    "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
    "instance_id": "server-001"
  }'

Response (first activation of this key/instance_id pair):

json
{
  "activated": true,
  "token": "b6f2b6e6a4f4e6e0b8f2b6e6a4f4e6e0"
}

token is the opaque credential this instance needs to later call deactivate-license — it's only returned on the call that creates the activation. Store it. If the same key/instance_id pair activates again (e.g. app restart), the response is instead:

json
{
  "activated": true,
  "alreadyActivated": true
}

with no token — the server doesn't re-issue or re-expose it, so hold on to the one you got the first time. If it's gone, the seat can only be released from outside: free it in the license portal or with POST /v1/admin/deactivate-by-instance-id, then activate again.

Don't retry activate-license automatically. If the server saved the activation but the response was lost, the retry gets alreadyActivated and no token. Treat the response the same way as on every other call: a 4xx other than 429 is a refusal (404 unknown key, 403 revoked, expired, or no seats left); 429, 5xx, a network error or a timeout is no answer. activate-license and deactivate-license each have a per-key limit of 20 requests per 15 minutes, counted separately since 1.14.0.

Key vs. token — which one actually needs protecting

The license key isn't a secret — it's meant to live in a distributed app, a config file, even public page source, and activate-license itself accepts nothing but {key, instance_id} to prove that. The token returned above is the real credential: it's what deactivate-license and track-usage require afterward as proof this specific instance holds a real activation, not just a guessed instance_id. It's generated once, shown to you exactly once (here), and never retrievable from the server again — there's no GET for it.

If your app loses its token (a wiped device, a corrupted config), the activation itself is still fully valid — verification and license enforcement don't need the token at all. The instance just can't self-service-deactivate or report usage anymore until an admin mints it a fresh one via POST /v1/admin/reissue-token, which invalidates the old token in the same call. There's no self-service recovery for a lost token by design — that's exactly the credential an attacker would also want, so only an admin can reissue one.

Instance metadata ​

The request body accepts only key and instance_id — nothing else. An earlier version of this guide showed an instance_info object (hostname, IP, platform, etc.) for "better tracking," but the server has never read or stored that field; it's silently discarded. If you need to correlate activations with instance metadata, track it in your own system alongside the instance_id you generate.

Instance ID Generation ​

Unique Instance Identifiers ​

Generate consistent, unique instance IDs:

javascript
const os = require('os');
const crypto = require('crypto');

class InstanceIDGenerator {
  static generate(includeTimestamp = false) {
    const hostname = os.hostname();
    const platform = os.platform();
    const arch = os.arch();
    const networkInterfaces = os.networkInterfaces();
    
    // Get MAC address from primary network interface
    const primaryInterface = this.getPrimaryNetworkInterface(networkInterfaces);
    const macAddress = primaryInterface?.mac || 'unknown';
    
    // Create base identifier
    const baseId = `${hostname}-${platform}-${arch}-${macAddress}`;
    
    // Add timestamp if requested (for unique instances). An ID with a
    // timestamp is only stable if you store it, as the manager below does;
    // a new ID on every launch takes a new seat every launch.
    const uniqueId = includeTimestamp ? 
      `${baseId}-${Date.now()}` : 
      baseId;
    
    // Hash to create consistent, shorter ID
    return crypto
      .createHash('sha256')
      .update(uniqueId)
      .digest('hex')
      .substring(0, 16);
  }

  static getPrimaryNetworkInterface(interfaces) {
    // Find the primary network interface (usually eth0, en0, etc.)
    const priorities = ['eth0', 'en0', 'wlan0', 'wi-fi'];
    
    for (const name of priorities) {
      if (interfaces[name]) {
        return interfaces[name].find(iface => !iface.internal);
      }
    }
    
    // Fallback to first non-internal interface
    for (const [name, ifaces] of Object.entries(interfaces)) {
      const external = ifaces.find(iface => !iface.internal);
      if (external) {
        return external;
      }
    }
    
    return null;
  }

  static generateForContainer() {
    // For containerized environments
    const containerId = process.env.HOSTNAME || 
                       process.env.CONTAINER_ID || 
                       crypto.randomBytes(8).toString('hex');
    
    return crypto
      .createHash('sha256')
      .update(`container-${containerId}`)
      .digest('hex')
      .substring(0, 16);
  }

  static generateForCloud(provider = 'aws') {
    // For cloud environments - fetch instance metadata
    switch (provider.toLowerCase()) {
      case 'aws':
        return this.generateAWSInstanceId();
      case 'gcp':
        return this.generateGCPInstanceId();
      case 'azure':
        return this.generateAzureInstanceId();
      default:
        return this.generate(true);
    }
  }

  static async generateAWSInstanceId() {
    try {
      const response = await fetch('http://169.254.169.254/latest/meta-data/instance-id', {
        signal: AbortSignal.timeout(2000)
      });
      
      if (response.ok) {
        const instanceId = await response.text();
        return `aws-${instanceId}`;
      }
    } catch (error) {
      console.warn('Failed to fetch AWS instance ID:', error.message);
    }
    
    return this.generate(true);
  }

  // Similar methods for GCP and Azure...
}

// Usage
const instanceId = InstanceIDGenerator.generate();
console.log('Instance ID:', instanceId);

Platform-Specific Generators ​

javascript
const os = require('os');

class PlatformSpecificInstanceId {
  static generate() {
    const platform = os.platform();
    
    switch (platform) {
      case 'win32':
        return this.generateWindows();
      case 'darwin':
        return this.generateMacOS();
      case 'linux':
        return this.generateLinux();
      default:
        return this.generateGeneric();
    }
  }

  static generateWindows() {
    const { execSync } = require('child_process');
    
    try {
      // Get Windows machine GUID
      const output = execSync(
        'wmic csproduct get uuid /format:value', 
        { encoding: 'utf8', timeout: 5000 }
      );
      
      const match = output.match(/UUID=(.+)/);
      if (match && match[1] && match[1].trim() !== '') {
        return `win-${match[1].trim()}`;
      }
    } catch (error) {
      console.warn('Failed to get Windows UUID:', error.message);
    }
    
    return this.generateGeneric();
  }

  static generateMacOS() {
    const { execSync } = require('child_process');
    
    try {
      // Get macOS hardware UUID
      const output = execSync(
        'system_profiler SPHardwareDataType | grep "Hardware UUID"',
        { encoding: 'utf8', timeout: 5000 }
      );
      
      const match = output.match(/Hardware UUID:\s*(.+)/);
      if (match && match[1]) {
        return `mac-${match[1].trim()}`;
      }
    } catch (error) {
      console.warn('Failed to get macOS UUID:', error.message);
    }
    
    return this.generateGeneric();
  }

  static generateLinux() {
    const fs = require('fs');
    
    try {
      // Try to read machine-id
      if (fs.existsSync('/etc/machine-id')) {
        const machineId = fs.readFileSync('/etc/machine-id', 'utf8').trim();
        return `linux-${machineId}`;
      }
      
      // Fallback to DMI product UUID
      if (fs.existsSync('/sys/class/dmi/id/product_uuid')) {
        const productUuid = fs.readFileSync('/sys/class/dmi/id/product_uuid', 'utf8').trim();
        return `linux-${productUuid}`;
      }
    } catch (error) {
      console.warn('Failed to get Linux machine ID:', error.message);
    }
    
    return this.generateGeneric();
  }

  static generateGeneric() {
    return InstanceIDGenerator.generate(true);
  }
}

Complete Activation Implementation ​

Robust Activation Manager ​

The manager below keeps the instance ID and activation token in one small file, so both survive restarts. It verifies the key for your product before taking a seat, never retries activate-license, and tells a refusal from no answer: errors it throws carry error.refused, true when the server said no (a 4xx other than 429) and false when there was no answer (429, 5xx, a network error or a timeout).

javascript
const fs = require('fs');
const os = require('os');
const path = require('path');

// Resolves { status, body } for any HTTP answer; rejects on a network
// error or timeout.
async function post(apiUrl, route, payload) {
  const response = await fetch(`${apiUrl}/${route}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
    signal: AbortSignal.timeout(30000)
  });
  return { status: response.status, body: await response.json().catch(() => ({})) };
}

// The body of a 200, or an Error with `refused` set as described above.
function expect200(route, { status, body }) {
  if (status === 200) return body;
  const error = new Error(`${route}: ${body.error || `HTTP ${status}`}`);
  error.refused = status >= 400 && status < 500 && status !== 429;
  throw error;
}

class LicenseActivationManager {
  constructor(licenseKey, apiUrl, productUuid, options = {}) {
    this.licenseKey = licenseKey;
    this.apiUrl = apiUrl;
    this.productUuid = productUuid; // your product's product_uuid, hard-coded
    this.stateFile = options.stateFile || path.join(os.homedir(), '.myapp', 'activation.json');
    this.maxOffline = (options.maxOfflineHours ?? 24) * 60 * 60 * 1000;
    // { instanceId, token, lastGoodCheck } - kept across restarts. An ID
    // already in the file wins over options.instanceId, so a stored token
    // always matches the instance it was issued to.
    this.state = this.loadState();
    this.state.instanceId ||= options.instanceId || InstanceIDGenerator.generate();
    this.saveState();
    this.isActivated = false;
  }

  get instanceId() { return this.state.instanceId; }

  async activate() {
    try {
      return await this.verifyAndActivate();
    } catch (error) {
      // A refusal ends any offline allowance: the server said no.
      if (error.refused) this.forgetGoodCheck();
      throw error;
    }
  }

  async verifyAndActivate() {
    // 1. Is the key good, and for this product? activate-license alone
    //    doesn't check the product.
    const license = expect200('verify-license',
      await post(this.apiUrl, 'verify-license', { key: this.licenseKey }));
    if (license.product_uuid !== this.productUuid) {
      const error = new Error('verify-license: License is for a different product');
      error.refused = true;
      throw error;
    }

    // 2. Take a seat, or confirm this instance's existing one. Not retried:
    //    if the server saved the activation but the response was lost, a
    //    retry gets `alreadyActivated` with no token, and the seat can't be
    //    released by this instance.
    const result = expect200('activate-license',
      await post(this.apiUrl, 'activate-license', { key: this.licenseKey, instance_id: this.instanceId }));

    if (result.token) {
      this.state.token = result.token; // only returned on the first activation
    } else if (!this.state.token) {
      console.warn(
        `Instance ${this.instanceId} holds a seat but its activation token is lost, so it ` +
        'can\'t deactivate or track usage. Free the seat in the license portal or with ' +
        'POST /v1/admin/deactivate-by-instance-id, then activate again (or have an admin ' +
        'reissue the token with POST /v1/admin/reissue-token).'
      );
    }
    this.markGoodCheck();
    this.isActivated = true;
    return result;
  }

  // true/false when the server answered; throws (error.refused === false)
  // when it didn't, so an outage isn't mistaken for a freed seat.
  async validateActivation() {
    const result = expect200('validate-license',
      await post(this.apiUrl, 'validate-license', { key: this.licenseKey, instance_id: this.instanceId }));
    if (result.valid === true) this.markGoodCheck();
    else this.forgetGoodCheck();
    return result.valid === true;
  }

  // true when this call freed the seat. false when there was nothing to
  // free: no token, or the token's seat was already freed elsewhere.
  // Throws when the server didn't answer; the token is kept for next time.
  async deactivate() {
    if (!this.state.token) return false;
    const result = expect200('deactivate-license',
      await post(this.apiUrl, 'deactivate-license', { key: this.licenseKey, token: this.state.token }));
    this.state.token = null; // either way, the server holds no seat for it now
    this.saveState();
    this.isActivated = false;
    return result.deactivated === true;
  }

  // For no-answer cases: is the last good check recent enough to keep running?
  withinOfflineAllowance() {
    return Boolean(this.state.lastGoodCheck) && Date.now() - this.state.lastGoodCheck < this.maxOffline;
  }

  markGoodCheck() {
    this.state.lastGoodCheck = Date.now();
    this.saveState();
  }

  forgetGoodCheck() {
    this.state.lastGoodCheck = null;
    this.saveState();
  }

  loadState() {
    try {
      return JSON.parse(fs.readFileSync(this.stateFile, 'utf8'));
    } catch {
      return {};
    }
  }

  saveState() {
    fs.mkdirSync(path.dirname(this.stateFile), { recursive: true });
    fs.writeFileSync(this.stateFile, JSON.stringify(this.state), { mode: 0o600 });
  }

  // Not sent to the license server (it only accepts `key`/`instance_id`,
  // see the "Instance metadata" note above) -- this is for your own
  // local logs/database if you want to correlate an instance_id with
  // more detail than the ID alone carries.
  gatherInstanceInfo() {
    return {
      hostname: os.hostname(),
      platform: os.platform(),
      arch: os.arch(),
      node_version: process.version,
      cpu_count: os.cpus().length,
      process_id: process.pid,
      activation_time: new Date().toISOString()
    };
  }
}

Activation Lifecycle Management ​

Application Integration ​

javascript
class ApplicationWithLicenseActivation {
  constructor(licenseKey, apiUrl, productUuid) {
    this.activationManager = new LicenseActivationManager(licenseKey, apiUrl, productUuid);
    this.isRunning = false;
    this.validationInterval = null;
  }

  async start() {
    try {
      console.log('Activating license...');
      const activation = await this.activationManager.activate();
      if (activation.alreadyActivated) {
        console.log('(reused an existing activation for this instance)');
      }
    } catch (error) {
      console.error(error.refused ? 'License refused:' : "Can't reach the license server:", error.message);
      process.exit(1);
    }

    this.isRunning = true;
    await this.startApplication();
    this.startPeriodicValidation();
    this.setupShutdownHandlers();
  }

  async startApplication() {
    // Start your application logic here
    console.log('Application started successfully');
  }

  // Hourly is plenty: validate-license shares a per-IP budget of 1000
  // requests per 15 minutes with verify-license and track-usage.
  startPeriodicValidation(intervalMinutes = 60) {
    this.validationInterval = setInterval(async () => {
      const manager = this.activationManager;
      try {
        if (await manager.validateActivation()) return;
        // The server answered: this instance no longer holds a seat (freed
        // elsewhere), or the license was revoked or has expired.
        console.error('This instance is no longer activated - shutting down');
        await this.gracefulShutdown();
      } catch (error) {
        if (!error.refused && manager.withinOfflineAllowance()) {
          console.warn('License validation got no answer; continuing on the last good check:', error.message);
          return;
        }
        console.error('License validation failed - shutting down:', error.message);
        await this.gracefulShutdown();
      }
    }, intervalMinutes * 60 * 1000);
  }

  setupShutdownHandlers() {
    const onSignal = async (signal) => {
      console.log(`Received ${signal} - shutting down gracefully`);
      await this.gracefulShutdown();
    };

    process.on('SIGTERM', onSignal);
    process.on('SIGINT', onSignal);
    process.on('SIGQUIT', onSignal);
  }

  async gracefulShutdown() {
    if (!this.isRunning) return;
    this.isRunning = false;
    clearInterval(this.validationInterval);

    try {
      await this.stopApplication();
      const freed = await this.activationManager.deactivate();
      console.log(freed ? 'Seat released' : 'No seat to release (already freed, or no token)');
    } catch (error) {
      // No answer: the token is still saved, so the next run can release it.
      console.error('Could not release the seat:', error.message);
    } finally {
      process.exit(0);
    }
  }

  async stopApplication() {
    // Stop your application logic here
    console.log('Application stopped');
  }
}

// Usage
const app = new ApplicationWithLicenseActivation(
  process.env.LICENSE_KEY,
  'https://your-license-api.com/v1',
  'b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8'
);

app.start();

Activation Management for Different Scenarios ​

Desktop Applications ​

A desktop app restarts often. Rather than activating on every launch, it asks the server whether its stored activation still holds, and activates again only when it doesn't. It never trusts the local file on its own: the file says which instance and token this machine has, not whether the server still agrees.

javascript
class DesktopAppActivation extends LicenseActivationManager {
  async start() {
    if (this.state.token) {
      try {
        if (await this.validateActivation()) {
          this.isActivated = true;
          console.log('Using existing license activation');
          return { activated: true, alreadyActivated: true };
        }
        // valid: false - the seat was freed elsewhere, or the license is
        // no longer good. activate() re-checks the key and takes a seat
        // again if it can.
      } catch (error) {
        if (!error.refused && this.withinOfflineAllowance()) {
          this.isActivated = true;
          console.warn('License server unreachable; running on the last good check');
          return { activated: true, offline: true };
        }
        throw error;
      }
    }
    return this.activate();
  }
}

Server Applications ​

javascript
class ServerAppActivation extends LicenseActivationManager {
  constructor(licenseKey, apiUrl, productUuid, serverConfig = {}) {
    const instanceId = serverConfig.instanceId ||
                     process.env.INSTANCE_ID ||
                     InstanceIDGenerator.generate();

    // Keep stateFile on persistent storage: in a container, a state file
    // that's lost on redeploy loses the token with it.
    super(licenseKey, apiUrl, productUuid, { instanceId, stateFile: serverConfig.stateFile });
    
    this.serverConfig = serverConfig;
    this.healthCheckInterval = null;
  }

  async activate() {
    const result = await super.activate();
    
    // Start health reporting
    this.startHealthReporting();
    
    return result;
  }

  gatherInstanceInfo() {
    const baseInfo = super.gatherInstanceInfo();
    
    return {
      ...baseInfo,
      server_type: this.serverConfig.type || 'unknown',
      server_role: this.serverConfig.role || 'primary',
      datacenter: this.serverConfig.datacenter || 'unknown',
      environment: process.env.NODE_ENV || 'development',
      load_balancer_id: this.serverConfig.loadBalancerId,
      cluster_id: this.serverConfig.clusterId
    };
  }

  startHealthReporting(intervalMinutes = 30) {
    this.healthCheckInterval = setInterval(async () => {
      try {
        await this.reportHealth();
      } catch (error) {
        console.warn('Health reporting failed:', error.message);
      }
    }, intervalMinutes * 60 * 1000);
  }

  async reportHealth() {
    const health = this.gatherHealthInfo();
    
    try {
      console.log('Instance health:', health);
    } catch (error) {
      console.warn('Health check failed:', error.message);
    }
  }

  gatherHealthInfo() {
    const os = require('os');
    
    return {
      timestamp: new Date().toISOString(),
      uptime: process.uptime(),
      memory_usage: process.memoryUsage(),
      cpu_usage: process.cpuUsage(),
      system_load: os.loadavg(),
      free_memory: os.freemem(),
      total_memory: os.totalmem()
    };
  }

  async deactivate() {
    if (this.healthCheckInterval) {
      clearInterval(this.healthCheckInterval);
      this.healthCheckInterval = null;
    }
    
    return super.deactivate();
  }
}

Seats that free themselves ​

License Server 1.14.0 and later; the console setting needs console 1.10.0.

A machine takes a seat when it activates and keeps it until something deactivates it. Machines often go away without doing that: a customer reinstalls the operating system, replaces a laptop, or a computer dies. Each one keeps its seat, until one day the customer's new machine is refused with Activation limit exceeded for this license key and they have to free a seat in the license portal or ask you to.

A lease fixes that. You choose a length, say 90 days, and a machine that hasn't checked in for that long gives up its seat when another machine needs one. Leases are off unless you turn them on, so nothing changes for licenses you don't set one on.

Turning it on ​

In the console: Free inactive machines, on the issue form, in Edit terms on a license, and on a storefront mapping. Choose Never (the default), After 30 days, After 90 days or Custom (1 to 365 days). The setting appears only when the console is connected to License Server 1.14.0 or later.

With the API: lease_seconds, a whole number of seconds from 86400 (1 day) to 31536000 (365 days), or null for no lease. It's accepted by POST /v1/issue-license, POST /v1/admin/update-license-terms and storefront mappings. For example, 90 days on an existing license:

bash
curl -X POST https://your-license-api.com/v1/admin/update-license-terms \
  -H "Authorization: Bearer $ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
    "lease_seconds": 7776000
  }'

What counts as a check-in ​

Your app doesn't need a new SDK or any new call. A machine checks in whenever it makes one of these calls:

  • POST /v1/validate-license with its instance_id: the SDK's validate(), the launch check the desktop guide recommends.
  • POST /v1/track-usage with its activation token.
  • POST /v1/activate-license again, with the same key and instance_id. This counts against activation's per-key limit of 20 requests per 15 minutes, shared by every machine on the key, so make validate() your launch check-in rather than activating on every launch.

Other calls don't count: verify-license (the SDK's verifyKey), verifying an offline license file, and check-update don't identify a machine, and never look at activations. If your app only verifies the key or a license file, leave leases off. Its machines never check in, so every one of them lapses one lease after it activates and its seat goes to the next machine that asks. The machine that lost its seat keeps running and never notices, because nothing it calls checks seats. For such an app, the seat limit stops holding after one lease.

Offline license files aren't seat-limited at all: a file keeps working until the license's expires_at whether or not its machine still holds a seat. A machine whose seat is reclaimed while it runs from an offline file carries on until its next online validate().

A check-in renews the lease only once less than half of it is left, which saves a database write on every launch. The comparison is strict: a machine checking in with exactly half the lease remaining — day 45 of a 90-day lease — does not renew, so half the lease is the boundary, not a safe interval. Check in comfortably inside it (say every 40 days on a 90-day lease) and a machine never goes inactive. Its last check-in time is accurate to within half the lease.

What happens to a machine that stops checking in ​

When a machine's lease runs out, it becomes inactive (lapsed in the API). Nothing happens to it yet:

  • It keeps working. It still holds its seat, still validates, and its next check-in renews the lease as if nothing had happened.
  • It still counts toward the seats. A lease never lets a license go over its seats: a new machine gets in only by replacing lapsed ones.
  • The console's machine list marks it Inactive — its seat goes to the next machine that needs one, and the customer's license portal shows when each machine was last seen and marks inactive ones the same way.

Its seat is taken only when a new machine activates and no seat is free. License Server then removes inactive machines, the one whose lease ran out longest ago first, until the license is one below its seats: one machine if the license was exactly full, more if it was already over (after you lowered its seats, or a storefront seat cut, neither of which deactivates anything). If there aren't that many inactive machines, the new activation is refused as it always was, and nothing is removed.

A machine that has been removed finds out at its next check-in: validate-license answers {"valid": false}, and track-usage answers 403 Invalid activation token. That is the same answer as when a customer frees a machine from the portal, so an app that handles that already does the right thing: ask the customer to activate this machine again. Activating again works if a seat is free, or if another machine's lease has run out, and returns a new token.

Choosing a length ​

  • More than twice the longest gap between uses of your app. Twice the gap is the boundary, not a safe floor: renewal needs less than half the lease remaining, so a machine checking in at exactly half does not renew. For an app used every week, 30 days is plenty; for one used now and then, 90 days or more.
  • Allow for offline use. A machine that runs without a network connection doesn't check in. If your customers can use your app offline (with an offline license file), make the lease at least twice the longest time offline you allow.
  • An inactive machine isn't cut off until a new machine needs its seat, so a lease that's too short costs a customer a seat only when they're also activating somewhere else. A longer lease is the safer choice; seats held by gone machines just come back more slowly.

Existing licenses ​

Setting a lease on a license that already has machines activated starts a full lease for each of them from that moment. Machines still in use renew it as they check in; machines that are long gone become inactive one lease later, and their seats are free for the next machine that needs one. A machine's first check-in time is recorded when it first renews, once less than half of that lease is left, so until then the console shows its last check-in as Not yet since the setting was turned on, even for a machine in daily use.

  • Shortening a lease cuts any machine's remaining lease that is longer than the new length down to it, counted from the change.
  • Lengthening it applies to each machine from its next renewal.
  • Turning it off (Never, or null) makes every seat permanent again.

Storefront licenses get their mapping's lease when they're issued. On Stripe subscriptions, set it on both the Payment Link mapping and the price mapping: the price mapping's terms replace the Payment Link's when the subscription's first event arrives. Mappings can't be edited, so to add a lease to a plan, delete the mapping, create it again with Free inactive machines set, and Apply it to the subscriptions already on that plan. A subscription's next plan change, a seat change on a per-seat (activations per unit) plan, or Apply sets its license's lease to its mapping's, replacing one you set on the license by hand. Licenses from one-time purchases aren't reached by Apply: set the lease on each with Edit terms or POST /v1/admin/update-license-terms.

This isn't floating licensing ​

A lease is at least a day long. It gives back seats held by machines that are gone, not by apps that are closed: closing your app doesn't free its seat, and it doesn't let ten people share five seats by taking turns. Floating (concurrent) licensing, with short leases that free a seat minutes after the app closes, isn't offered. To move a seat at once, deactivate it: from the app (deactivate-license), the license portal, or the console.

One limit to know: validate-license needs only the license key and an instance_id, so anyone who has both can keep that machine's seat from being given away. They can't take a seat this way. You or the customer can still free that machine from the console or the portal.

Handling Activation Limits ​

When a customer's new machine is refused because every seat is taken, free one of the old ones: in the console, open the license and choose Deactivate on that machine under Activations. The customer can do the same from the license portal. To do it from your own backend instead, use the API, as below. A lease frees seats held by machines that are gone without anyone doing this.

Smart Activation Management ​

An earlier version of this pattern auto-discovered an inactive instance via admin-only GET /v1/list-activations/:key and force-freed its seat by instance_id, using POST /v1/deactivate-license. That specific approach never actually worked: deactivate-license requires the opaque token issued to the specific instance at activation time (see License Activation above), and that token is never exposed again anywhere — including in the list-activations response, which returns each machine's instance_id and activated_at (and, on a license with a lease, its lease state) but never its token. An admin discovering an inactive instance this way has no way to obtain its token, so it can't be freed through deactivate-license.

There is now a real admin-only recovery path for exactly this case, though: POST /v1/admin/deactivate-by-instance-id force-frees a seat by key/instance_id directly, with no token required — the same admin credential that already gates list-activations is sufficient. This class lists the activations via list-activations (flagging any activated over a day ago; without a lease the API has no last-seen time) and reports them rather than force-deactivating automatically — deciding which instance to free is a real business decision (the "inactive" one might just be a laptop that's asleep, not actually abandoned), so this stays a deliberate, explicit admin action via forceDeactivateInstance() below, not something triggered automatically on every activation-limit error. POST /v1/revoke-license (invalidating the entire license, not just one seat) remains available as a separate, more drastic option, but is no longer the only one.

If what you want is for seats held by machines that have stopped being used to come back on their own, give the license a lease instead: see Seats that free themselves above.

javascript
class SmartActivationManager extends LicenseActivationManager {
  // options.adminToken: your admin API key. Only for code running on your
  // own backend - never ship it in a client app.
  constructor(licenseKey, apiUrl, productUuid, options = {}) {
    super(licenseKey, apiUrl, productUuid, options);
    this.adminToken = options.adminToken;
  }

  async activate() {
    try {
      return await super.activate();
    } catch (error) {
      if (error.message.includes('Activation limit exceeded')) {
        return await this.handleActivationLimitReached();
      }
      throw error;
    }
  }

  async handleActivationLimitReached() {
    // Reported to the admin, not auto-freed. Call forceDeactivateInstance()
    // below once a human has decided which one (if any) is safe to free.
    const activations = await this.getActivations();
    this.reportActivations(activations, this.findOldActivations(activations));
  }

  // NOTE: GET /v1/list-activations/:key requires admin authentication —
  // it returns per-device instance IDs and timestamps, which is sensitive
  // enough that it isn't a public endpoint. This means this class only
  // works where an admin token is held (e.g. your own backend, acting on
  // the customer's behalf) — not in an installed client app.
  async getActivations() {
    const response = await fetch(
      `${this.apiUrl}/list-activations/${encodeURIComponent(this.licenseKey)}`,
      {
        headers: { Authorization: `Bearer ${this.adminToken}` },
        signal: AbortSignal.timeout(30000)
      }
    );
    if (!response.ok) {
      const error = new Error(`list-activations failed: HTTP ${response.status}`);
      error.refused = response.status >= 400 && response.status < 500 && response.status !== 429;
      throw error;
    }
    const data = await response.json();
    return data.activations; // [{ instance_id, activated_at, lease_expires_at, last_seen_at, lapsed }]
  }

  // Parses a time the server records, such as activated_at, as UTC: it
  // adds the missing "Z" (and turns the space into a "T"), takes an ISO
  // 8601 value ending in Z or a +-HH:MM / +-HHMM offset as it is, and
  // returns null for a missing value.
  parseTime(value) {
    if (value == null) return null;
    return new Date(/[zZ]|[+-]\d\d:?\d\d$/.test(value) ? value : value.replace(' ', 'T') + 'Z');
  }

  // Without a lease the API has no last-seen time - only activated_at
  // (ISO 8601 from License Server 1.16.0; before it, "YYYY-MM-DD HH:MM:SS"
  // in UTC with no zone marker, which parseTime also reads). So this
  // finds seats *activated* more than a day ago, which says nothing about
  // whether they're still in use. (On a license with a lease, last_seen_at
  // and lapsed say more.)
  findOldActivations(activations, olderThanHours = 24) {
    return activations.filter(activation => {
      const activatedAt = this.parseTime(activation.activated_at).getTime();
      return Date.now() - activatedAt > olderThanHours * 60 * 60 * 1000;
    });
  }

  reportActivations(activations, oldActivations) {
    console.log('Current license activations:');
    activations.forEach((activation, index) => {
      const old = oldActivations.includes(activation) ? ' (over a day old)' : '';
      const activated = this.parseTime(activation.activated_at).toISOString();
      console.log(`${index + 1}. ${activation.instance_id} (activated: ${activated})${old}`);
    });

    const error = new Error(
      'License activation limit reached. An admin can free a seat via ' +
      'forceDeactivateInstance() (see the instance list above), ask the owner of an ' +
      'unused instance to deactivate it themselves, or upgrade your license.'
    );
    error.refused = true;
    throw error;
  }

  // Admin-only recovery: force-frees a seat by instance_id, with no
  // token required from that instance. A deliberate, explicit call, not
  // something handleActivationLimitReached() triggers on its own.
  async forceDeactivateInstance(instanceId) {
    const response = await fetch(`${this.apiUrl}/admin/deactivate-by-instance-id`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${this.adminToken}`
      },
      body: JSON.stringify({ key: this.licenseKey, instance_id: instanceId }),
      signal: AbortSignal.timeout(30000)
    });

    const result = await response.json();
    if (!response.ok) {
      throw new Error(result.error || `HTTP ${response.status}`);
    }

    console.log(`Force-deactivated instance: ${instanceId}`);
    return result; // { deactivated: true }
  }
}

Best Practices ​

Instance Management ​

  • Generate consistent instance IDs across application restarts
  • Store the activation token with the instance ID; the server shows it only once
  • Validate against the server at startup rather than trusting a local file
  • Include meaningful instance information for debugging
  • Handle activation limit scenarios gracefully

For general integration best practices (error handling, performance, security, monitoring), see the Integration Checklist.

Next Steps ​