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

License Verification ​

Detailed guide on implementing license verification in your applications, including caching strategies, offline support, and best practices.

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';

const client = new CasaziumLicenseClient({
  baseUrl: 'https://your-license-api.com/v1',
  // The SDK sets no timeout of its own. Without one, a server that
  // never answers holds up your app's start.
  fetch: (url, init) => fetch(url, { ...init, signal: AbortSignal.timeout(5000) }),
});

const license = await client.verifyKey(key, 'your-product-uuid');
console.log(license.tier, license.limits, license.expires_at);

verifyKey(key, expectedProductUuid) sends verify-license and resolves the response shown under Simple Verification. The second argument is required: your product's Product UUID, which the console shows on any of the product's licenses (see The license's page). Hard-code it in your app; it never changes.

verifyKey() throws rather than answering valid: false. It throws when the server refuses the key (unknown, revoked or expired), and when the key belongs to another product. So reaching the next line means licensed. What matters is whether the server said no or didn't answer: the error has the HTTP status on err.status (SDK 0.4.0 and later), and a network failure or timeout throws fetch's own error, with no status.

js
// The server said no: a 4xx other than 429, or verifyKey's own refusal
// of a license for another product (which has no status).
function isRefusal(err) {
  if (err.status === undefined) return /product_uuid mismatch/.test(err.message);
  // A 404 ending ": Not Found" is a route the server doesn't have (a server
  // older than the SDK needs, or a base URL missing /v1), not a refused key.
  if (err.status === 404 && /: Not Found$/.test(err.message)) return false;
  return err.status >= 400 && err.status < 500 && err.status !== 429;
}

try {
  await client.verifyKey(key, productUuid);
  // Licensed: record the time of this good check.
} catch (err) {
  if (isRefusal(err)) {
    // Revoked, expired, unknown, or another product's: treat the license
    // as invalid, and drop any offline file you keep for it.
  } else {
    // No answer: unreachable, timed out, rate limited (429), a server
    // error, or a route the server doesn't have. Keep running on the last
    // good check, within your own limit.
  }
}

Reading the answer explains the three outcomes. The SDK retries a network error or a 5xx up to retries times (default 3) before it throws; pass retries: 1 in the constructor for a faster start.

Two more checks the SDK makes:

  • Is this machine still activated? verifyKey() checks the license, not this machine, so it keeps passing after this machine's seat was freed. At launch, also call client.validate(key, instanceId): see License Activation.
  • No network at all? exportOfflineLicense() and verifySignedFile() check a signed license file with only your public key: see Cryptographic Offline Verification.

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 License Verification ​

Simple Verification ​

The most basic license verification checks if a key is valid:

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

Response:

json
{
  "valid": true,
  "tier": "pro",
  "product_id": "your-product",
  "product_uuid": "b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8",
  "limits": {
    "users": 100,
    "api_calls_per_day": 10000
  },
  "expires_at": "2025-12-31T23:59:59.000Z",
  "issued_at": "2025-01-01T00:00:00.000Z",
  "status": "active"
}

expires_at is ISO 8601 (or null for a perpetual license), and so is issued_at from License Server 1.16.0. Before 1.16.0, issued_at was YYYY-MM-DD HH:MM:SS in UTC with no zone marker, which JavaScript reads as local time. To handle both, parse it with:

js
const parseTime = (value) =>
  value == null
    ? null
    : new Date(/[zZ]|[+-]\d\d:?\d\d$/.test(value) ? value : value.replace(' ', 'T') + 'Z');

This adds the Z only when the value has no zone, so it also accepts ISO 8601 ending in Z or a ±HH:MM/±HHMM offset as it is, and returns null for a missing value (see the API Reference).

product_uuid is the license's real, immutable product identity - see Product IDs are per-tenant, not global. Compare it with your own product's UUID on every check: a valid key for a different product also comes back 200 {"valid": true}. If you're using @casazium/license-sdk, its verifyKey() requires this value and checks it for you.

Reading the answer ​

Every example on this page sorts a response into one of three outcomes:

OutcomeResponseWhat to do
Licensed200, valid: true, and product_uuid matches yoursRun, and record the time of this good check
RefusedAny 4xx except 429 (404 unknown key, 403 revoked or expired), or a valid key for another productBelieve it: drop cached results and offline files, clear the last-good time
No answer429, 5xx, a network error or a timeoutNot a verdict on the key. Keep the last known state, but only up to a maximum time offline measured from the last good check

Never report a no-answer as valid: false. Your users will see a license error during your outage, and a revoked key will look the same as a network hiccup. 429 responses carry a Retry-After header that can be many minutes; schedule the next check instead of waiting inline.

Verification with Instance Tracking ​

validate-license answers a different question: is this instance still activated? It returns 200 {"valid": true} or 200 {"valid": false}, with no reason, and doesn't include product_uuid. Use it alongside verify-license, not instead of it (see License Activation).

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

Implementation Patterns ​

1. Startup Verification ​

Verify the license when your application starts. checkKey() makes one verify-license call and sorts the response into the three outcomes above; the later examples on this page reuse it.

javascript
// One verify-license call, sorted into licensed / refused / no-answer.
async function checkKey(apiUrl, key, expectedProductUuid) {
  let response;
  try {
    response = await fetch(`${apiUrl}/verify-license`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ key }),
      signal: AbortSignal.timeout(10000)
    });
  } catch (error) {
    return { outcome: 'no-answer', error: error.message }; // network error or timeout
  }

  if (response.status === 429 || response.status >= 500) {
    return { outcome: 'no-answer', error: `HTTP ${response.status}` };
  }

  const body = await response.json().catch(() => null);
  if (!response.ok) {
    return { outcome: 'refused', error: body?.error || `HTTP ${response.status}` };
  }
  if (body?.valid !== true) {
    return { outcome: 'no-answer', error: 'Unreadable response' };
  }
  // product_id strings aren't unique; product_uuid is.
  if (body.product_uuid !== expectedProductUuid) {
    return { outcome: 'refused', error: 'License is for a different product' };
  }
  return { outcome: 'licensed', license: body };
}

class ApplicationBootstrap {
  constructor(licenseKey, apiUrl, productUuid) {
    this.licenseKey = licenseKey;
    this.apiUrl = apiUrl;
    this.productUuid = productUuid; // your product's product_uuid, hard-coded
    this.licenseInfo = null;
  }

  async initialize() {
    const result = await checkKey(this.apiUrl, this.licenseKey, this.productUuid);

    if (result.outcome === 'refused') {
      this.showLicenseError(`License refused: ${result.error}`);
      return false;
    }
    if (result.outcome === 'no-answer') {
      // Not a verdict on the key, so don't tell the user it's invalid.
      // To start without the server, see the caching and offline sections.
      this.showLicenseError(`Can't reach the license server (${result.error}). Try again shortly.`);
      return false;
    }

    this.licenseInfo = result.license;
    // verify-license deliberately omits issued_to (a privacy choice). Use
    // GET /v1/admin/license/:key (admin-authenticated) if you need it.
    console.log(`License tier: ${result.license.tier}`);
    this.startApplication(result.license);
    return true;
  }

  startApplication(license) {
    const limits = license.limits || {};
    if (limits.users !== undefined) this.setMaxUsers(limits.users);
    if (limits.api_calls_per_day !== undefined) this.setAPICallLimit(limits.api_calls_per_day);
    console.log('Application started successfully');
  }

  // Placeholders - replace with your application's own.
  setMaxUsers(max) {}
  setAPICallLimit(max) {}
  showLicenseError(message) { console.error(message); }
}

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

app.initialize();

2. Periodic Verification ​

Keep checking while the application runs, after the startup check above. A refusal stops the application at the next check; no answer keeps it running until maxOfflineHours have passed since the last good check (or since start(), if there hasn't been one yet).

javascript
class PeriodicLicenseChecker {
  constructor(licenseKey, apiUrl, productUuid, { intervalMinutes = 60, maxOfflineHours = 24 } = {}) {
    this.licenseKey = licenseKey;
    this.apiUrl = apiUrl;
    this.productUuid = productUuid;
    this.interval = intervalMinutes * 60 * 1000;
    this.maxOffline = maxOfflineHours * 60 * 60 * 1000;
    this.intervalId = null;
    this.lastGoodCheck = null; // time of the last licensed answer
  }

  start() {
    if (this.intervalId) return;
    this.startedAt = Date.now();
    this.checkLicense();
    this.intervalId = setInterval(() => this.checkLicense(), this.interval);
  }

  stop() {
    clearInterval(this.intervalId);
    this.intervalId = null;
  }

  async checkLicense() {
    const result = await checkKey(this.apiUrl, this.licenseKey, this.productUuid);

    if (result.outcome === 'licensed') {
      this.lastGoodCheck = Date.now();
      this.warnIfExpiringSoon(result.license);
    } else if (result.outcome === 'refused') {
      this.lastGoodCheck = null;
      this.handleRefusedLicense(result.error);
    } else if (Date.now() - (this.lastGoodCheck ?? this.startedAt) < this.maxOffline) {
      console.warn(`No answer from the license server (${result.error}); trying again at the next check.`);
    } else {
      console.error(`No answer from the license server for ${this.maxOffline / 3600000} hours; shutting down.`);
      this.initiateGracefulShutdown();
    }
  }

  warnIfExpiringSoon(license) {
    if (license.expires_at === null) return; // perpetual
    const daysUntilExpiry = (Date.parse(license.expires_at) - Date.now()) / (1000 * 60 * 60 * 24);
    if (daysUntilExpiry <= 30) {
      console.warn(`License expires in ${Math.floor(daysUntilExpiry)} days`);
    }
  }

  handleRefusedLicense(error) {
    // The API has no `expired`/`revoked` fields; an invalid license is
    // {valid: false, error: "<message>"}. "License is not active" covers
    // revoked and any other non-active status, so it can't tell those apart.
    if (error.includes('expired')) {
      console.error('License has expired - please renew');
    } else if (error.includes('not active')) {
      console.error('License has been revoked or suspended');
    } else {
      console.error(`License refused: ${error}`);
    }
    this.initiateGracefulShutdown();
  }

  initiateGracefulShutdown() {
    this.stop();
    // Replace with your application's own shutdown.
    process.exit(1);
  }
}

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

licenseChecker.start();
process.on('SIGTERM', () => licenseChecker.stop());

Caching Strategies ​

A cache saves calls and carries you through an outage. It must follow the same rule as everything else: a refusal empties it, and a cached result is only used for so long without a good check.

1. In-Memory Caching ​

javascript
// True once a license's own expires_at has passed (null = perpetual).
function licenseExpired(license) {
  return license.expires_at !== null && !(Date.parse(license.expires_at) > Date.now());
}

class CachedLicenseVerifier {
  constructor(apiUrl, productUuid, { cacheMinutes = 30, maxOfflineHours = 24 } = {}) {
    this.apiUrl = apiUrl;
    this.productUuid = productUuid;
    this.cache = new Map(); // key -> { license, checkedAt }
    this.cacheTTL = cacheMinutes * 60 * 1000;
    this.maxOffline = maxOfflineHours * 60 * 60 * 1000;
  }

  // Resolves to checkKey()'s { outcome, license?, error? }, plus
  // fromCache: true when the answer came from the cache.
  async verifyLicense(key) {
    const cached = this.cache.get(key);
    const age = cached ? Date.now() - cached.checkedAt : Infinity;
    const usable = cached && !licenseExpired(cached.license);

    if (usable && age < this.cacheTTL) {
      return { outcome: 'licensed', license: cached.license, fromCache: true };
    }

    const result = await checkKey(this.apiUrl, key, this.productUuid);

    if (result.outcome === 'licensed') {
      this.cache.set(key, { license: result.license, checkedAt: Date.now() });
      return result;
    }
    if (result.outcome === 'refused') {
      this.cache.delete(key); // believe the refusal
      return result;
    }
    // No answer: the last good result stands, within the offline limit.
    if (usable && age < this.maxOffline) {
      return { outcome: 'licensed', license: cached.license, fromCache: true };
    }
    return result;
  }

  clearCache(key = null) {
    if (key) this.cache.delete(key);
    else this.cache.clear();
  }
}

2. Persistent Caching ​

Store the last good result on disk so a restart during an outage still works. This is the same logic as above, with the cache in a file.

A local cache isn't tamper-proof

Anyone who can write to the cache file can change checkedAt or the cached license and run indefinitely without the server. That's fine for riding out outages for honest users; it isn't a way to trust a license offline. For that, use the signed export in Cryptographic Offline Verification below.

javascript
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');

class PersistentLicenseCache {
  constructor(cacheDir = './license-cache') {
    this.cacheDir = cacheDir;
  }

  filePath(key) {
    const keyHash = crypto.createHash('sha256').update(key).digest('hex').substring(0, 16);
    return path.join(this.cacheDir, `license-${keyHash}.json`);
  }

  // { license, checkedAt } or null. Doesn't delete old entries: the
  // caller decides what an entry's age allows.
  async get(key) {
    try {
      return JSON.parse(await fs.readFile(this.filePath(key), 'utf8'));
    } catch {
      return null; // missing or unreadable
    }
  }

  async set(key, license) {
    try {
      await fs.mkdir(this.cacheDir, { recursive: true });
      await fs.writeFile(this.filePath(key), JSON.stringify({ license, checkedAt: Date.now() }));
    } catch (error) {
      console.error('Failed to cache verification:', error.message);
    }
  }

  async remove(key) {
    await fs.unlink(this.filePath(key)).catch(() => {});
  }
}

// Uses checkKey() from Startup Verification and licenseExpired() from above.
class RobustLicenseVerifier {
  constructor(apiUrl, productUuid, { cacheMinutes = 60, maxOfflineHours = 72, cacheDir } = {}) {
    this.apiUrl = apiUrl;
    this.productUuid = productUuid;
    this.cacheTTL = cacheMinutes * 60 * 1000;
    this.maxOffline = maxOfflineHours * 60 * 60 * 1000;
    this.cache = new PersistentLicenseCache(cacheDir);
  }

  async verifyLicense(key) {
    const cached = await this.cache.get(key);
    const age = cached ? Date.now() - cached.checkedAt : Infinity;
    const usable = cached && !licenseExpired(cached.license);

    if (usable && age < this.cacheTTL) {
      return { outcome: 'licensed', license: cached.license, fromCache: true };
    }

    const result = await checkKey(this.apiUrl, key, this.productUuid);

    if (result.outcome === 'licensed') {
      await this.cache.set(key, result.license);
      return result;
    }
    if (result.outcome === 'refused') {
      await this.cache.remove(key);
      return result;
    }
    if (usable && age < this.maxOffline) {
      console.warn(`License server unreachable (${result.error}); using the result cached ${Math.round(age / 60000)} minutes ago`);
      return { outcome: 'licensed', license: cached.license, fromCache: true };
    }
    return result;
  }
}

Cryptographic Offline Verification ​

The caching strategies above are about resilience — surviving a temporary network outage using a previous verification result. This section covers something different: a license file that can be cryptographically verified with no server contact at all, not even once the network is back, using RSA public-key signatures.

This is distinct from the plain export, POST /v1/license-export (HMAC, shared secret), and POST /v1/verify-license-file / verify-license-file-base64 (which still check the database for revocation on every call). The offline-RSA path trades that live revocation check for true air-gapped verification.

With the SDK, fetch the file while online and check it later with only the public key:

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

// Online, once: fetch the public key (or embed it in your build) and the
// signed file, and store both.
const online = new CasaziumLicenseClient({ baseUrl: 'https://your-license-api.com/v1' });
const publicKey = await online.getPublicKey();
const file = await online.exportOfflineLicense(key); // { license, signature }

// At any time, with no network: true only for an authentic, unexpired
// license for your product.
const offline = new CasaziumLicenseClient({ publicKey });
const valid = await offline.verifySignedFile(file, 'your-product-uuid');

The rest of this section shows the same steps without the SDK, then the two rules for keeping a stored file honest.

Fetching the Public Key ​

Fetch this once and embed or cache it in your application — it isn't meant to be re-fetched before every verification:

bash
curl https://your-license-api.com/v1/public-key
json
{ "publicKey": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n" }

Fetching a Signed License ​

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

From License Server 1.21.0 the key goes in the request body, so proxies and server logs don't record it. Servers before 1.21.0 have only GET /v1/export-license/<key>/offline, now deprecated: see Export a license: key in the body. The SDK's exportOfflineLicense() above uses the POST from SDK 0.5.0, which needs License Server 1.21.0 or later. SDK 0.4.x and earlier use the GET, which keeps working.

json
{
  "license": {
    "type": "license",
    "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
    "tier": "pro",
    "product_id": "my-product",
    "product_uuid": "b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8",
    "expires_at": "2027-12-31T23:59:59.000Z",
    "limits": {},
    "issued_at": "2025-01-01T00:00:00.000Z",
    "exported_at": "2026-06-01T08:00:03.512Z",
    "livemode": true,
    "paid_through": "2027-12-24T23:59:59.000Z"
  },
  "signature": "base64-encoded-rsa-signature"
}

The last four fields are new in License Server 1.20.0: see What the file signs.

Verifying Fully Offline ​

javascript
import { createVerify } from 'node:crypto';

function verifyOfflineLicense(license, signature, publicKey, expectedProductUuid) {
  const verifier = createVerify('SHA256');
  verifier.update(JSON.stringify(license));
  verifier.end();
  const authentic = verifier.verify(publicKey, signature, 'base64');
  // A signature only proves the bytes weren't tampered with. Three more
  // checks, all of which @casazium/license-sdk's verifySignedFile() makes
  // for you:
  // - it's a license: release manifests are signed with the same key;
  // - it's for *your* product (see "Product IDs are per-tenant, not
  //   global" in the API reference);
  // - it hasn't expired: null is perpetual, and an unparseable date
  //   parses as NaN, so it's refused rather than read as perpetual.
  const expiry = license.expires_at === null ? Infinity : Date.parse(license.expires_at);
  return (
    authentic &&
    license.type === 'license' &&
    license.product_uuid === expectedProductUuid &&
    expiry > Date.now()
  );
}

With the SDK (0.3.0+), the same in one call — it resolves to false on anything else, including a malformed file:

javascript
const valid = await client.verifySignedFile({ license, signature }, expectedProductUuid);

A file exported before License Server 1.10.0 has no type and is refused: export it again.

Verify the license object exactly as received. Sign-check JSON.stringify of the parsed object, in the order the response gave it. Don't rebuild it from the fields you know, sort its keys, or round-trip it through a typed struct or class: later releases add fields after limits, and a rebuilt object fails verification. Treat every field after limits as optional. The SDK already does this, and so does the code above, because it passes license straight through.

What the file signs ​

From License Server 1.20.0, the file also signs these fields after limits, in this order. Older servers send none of them, so treat a missing one as "not known":

FieldWhat it is
issued_atWhen the license was issued.
exported_atThe server's clock when it signed the file.
livemodetrue for a license from a live storefront purchase, false for a sandbox (test-mode) one. Left out when unknown, for example for a license you issued by hand.
paid_throughThe end of the latest period the buyer paid for with money. Left out when there is none. A free trial or a 100% coupon doesn't extend it, and a refund takes it back.

Because they're signed, your app can trust them once the signature checks out, with no server call:

  • Limit offline use by exported_at. Count time offline from the file's exported_at rather than from a timestamp your app saved itself, which a user can edit. If the computer's clock reads well before exported_at (allow some clock difference, say a day), the clock was set back.
  • Reject sandbox licenses. A file with livemode: false came from a test-mode purchase. Your app can refuse it, even for files exported before you refused sandbox licenses on the server.
  • Keep what was paid for. paid_through is the date to base a "keep what you paid for" rule on, for example letting a buyer keep the builds released before it after a subscription ends.

With the SDK, read them from file.license once verifySignedFile() resolves true:

js
if (await client.verifySignedFile(file, 'your-product-uuid')) {
  const { exported_at, livemode, paid_through } = file.license;
  if (livemode === false) {
    // a sandbox (test-mode) purchase: refuse it
  }
  // count time offline from exported_at, not from the local clock alone
}

This confirms the license payload hasn't been tampered with since it was exported and hasn't expired, using only the cached public key — no server round-trip. What it does not know is whether the license has been revoked since export: a file for a perpetual license verifies forever. If live revocation status matters for your product, use verify-license-file/verify-license-file-base64 instead, or re-fetch the offline export when you can reach the server, with two rules:

  • Drop the file when the server refuses the key. A revoked or expired key gets 403 from the export route (404 for an unknown key). If you keep the old file after that, a revoked user stays licensed.
  • Cap the time offline. Count from the last successful re-fetch, and stop accepting the stored file once that's too long ago. Without a cap, a user can stay offline on purpose and never see a revocation. With a file from 1.20.0 or later, count from its signed exported_at.

429, 5xx, a network error, or a 404 ending : Not Found (a server older than 1.21.0, under SDK 0.5.0 and later) on the re-fetch isn't a refusal: keep the stored file. With the SDK (0.4.0+, which puts the HTTP status on err.status):

javascript
// `store` is wherever you keep { file, lastOnlineCheck } between runs.
async function checkOfflineLicense(client, key, productUuid, store, maxOfflineDays = 30) {
  try {
    const file = await client.exportOfflineLicense(key); // throws on any non-2xx
    if (!(await client.verifySignedFile(file, productUuid))) {
      await store.clear(); // not a license for this product
      return false;
    }
    await store.save({ file, lastOnlineCheck: Date.now() });
    return true;
  } catch (error) {
    // A 404 ending ": Not Found" is a route the server doesn't have (SDK
    // 0.5.0+ against a server older than 1.21.0), not a refused key.
    const routeMissing = error.status === 404 && /: Not Found$/.test(error.message);
    const refused = !routeMissing &&
      error.status >= 400 && error.status < 500 && error.status !== 429;
    if (refused) {
      await store.clear(); // revoked, expired or unknown: believe it
      return false;
    }
    // No answer: fall through to the stored file.
  }

  const saved = await store.load();
  if (!saved) return false;
  // From License Server 1.20.0 the file signs exported_at; older files
  // don't, so fall back to the time saved locally.
  const exportedAt = Date.parse(saved.file.license.exported_at);
  const since = Number.isNaN(exportedAt) ? saved.lastOnlineCheck : exportedAt;
  const SKEW_MS = 24 * 60 * 60 * 1000; // allow for a clock a little behind
  if (Date.now() + SKEW_MS < since) return false; // the clock was set back
  const offlineFor = Date.now() - since;
  return offlineFor < maxOfflineDays * 24 * 60 * 60 * 1000 &&
    (await client.verifySignedFile(saved.file, productUuid));
}

lastOnlineCheck lives on the user's machine, so it can be edited; the signed exported_at and expires_at can't be. The computer's clock can still be wound back to make the time offline look shorter, but not to well before exported_at. For a machine that is never online, nothing can see a revocation: issue those licenses with an expiry and renew them.

Feature Gating Based on License ​

License-Based Feature Control ​

javascript
class FeatureManager {
  constructor(licenseInfo) {
    this.licenseInfo = licenseInfo;
    this.features = new Map();
    this.initializeFeatures();
  }

  initializeFeatures() {
    const tier = this.licenseInfo.tier;
    const limits = this.licenseInfo.limits || {};

    // Define features based on license tier
    this.features.set('basic_features', true);
    this.features.set('advanced_features', ['pro', 'enterprise'].includes(tier));
    this.features.set('premium_features', tier === 'enterprise');
    this.features.set('api_access', true);
    this.features.set('bulk_operations', tier !== 'basic');
    this.features.set('custom_integrations', tier === 'enterprise');

    // Set usage limits
    this.setUsageLimits(limits);
  }

  setUsageLimits(limits) {
    // Keys here must be from the server's fixed allowlist:
    // users, seats, admins, projects, environments, tenants,
    // api_calls_per_day, rate_limit_rps, concurrent_sessions, features.
    // There is no storage-related limit key. `??`, not `||`: a limit of 0
    // means none allowed, not unlimited.
    this.limits = {
      maxUsers: limits.users ?? Infinity,
      maxAPICallsPerDay: limits.api_calls_per_day ?? Infinity,
      maxConcurrentSessions: limits.concurrent_sessions ?? Infinity,
      maxProjects: limits.projects ?? Infinity
    };
  }

  isFeatureEnabled(featureName) {
    return this.features.get(featureName) || false;
  }

  checkUsageLimit(limitType, currentUsage) {
    const limit = this.limits[limitType];
    if (limit === Infinity) return { allowed: true };

    const allowed = currentUsage < limit;
    const remaining = Math.max(0, limit - currentUsage);
    const percentage = limit > 0 ? (currentUsage / limit) * 100 : 100;

    return {
      allowed,
      limit,
      current: currentUsage,
      remaining,
      percentage
    };
  }

  getFeatureList() {
    return Array.from(this.features.entries()).map(([name, enabled]) => ({
      name,
      enabled
    }));
  }

  getLimits() {
    return { ...this.limits };
  }
}

// Usage in application
class MyApplication {
  constructor(licenseKey, productUuid) {
    this.licenseKey = licenseKey;
    this.productUuid = productUuid;
    this.featureManager = null;
  }

  async initialize() {
    // RobustLicenseVerifier from Persistent Caching above
    const verifier = new RobustLicenseVerifier('https://your-license-api.com/v1', this.productUuid);
    const result = await verifier.verifyLicense(this.licenseKey);

    if (result.outcome === 'refused') {
      throw new Error(`License refused: ${result.error}`);
    }
    if (result.outcome === 'no-answer') {
      throw new Error(`Can't reach the license server: ${result.error}`);
    }

    this.featureManager = new FeatureManager(result.license);
    
    console.log('Available features:', this.featureManager.getFeatureList());
    console.log('Usage limits:', this.featureManager.getLimits());
  }

  async createProject(projectData) {
    // Check if feature is enabled
    if (!this.featureManager.isFeatureEnabled('basic_features')) {
      throw new Error('Project creation not available in your license');
    }

    // Check usage limits
    const currentProjects = await this.getCurrentProjectCount();
    const limitCheck = this.featureManager.checkUsageLimit('maxProjects', currentProjects);
    
    if (!limitCheck.allowed) {
      throw new Error(`Project limit reached: ${limitCheck.current}/${limitCheck.limit}`);
    }

    // Create project
    return this.doCreateProject(projectData);
  }

  async makeAPICall() {
    const currentCalls = await this.getCurrentAPICallCount();
    const limitCheck = this.featureManager.checkUsageLimit('maxAPICallsPerDay', currentCalls);

    if (!limitCheck.allowed) {
      throw new Error(`API call limit reached: ${limitCheck.current}/${limitCheck.limit}`);
    }

    if (limitCheck.percentage > 80) {
      console.warn(`API usage warning: ${limitCheck.percentage.toFixed(1)}% of limit used`);
    }

    return this.doMakeAPICall();
  }

  // Placeholder methods
  async getCurrentProjectCount() { return 0; }
  async getCurrentAPICallCount() { return 0; }
  async doCreateProject(data) { return { id: 1, ...data }; }
  async doMakeAPICall() { return { success: true }; }
}

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

Next Steps ​