Client Examples
Platform-specific implementations and examples for integrating the License Server API into various programming languages and frameworks.
Building a desktop application? Licensing a JavaScript desktop app walks the whole flow — activation, the launch check, offline handling and moving machines — rather than one endpoint at a time, and the License demo is a small open-source Electron app that does all of it against a live demo server.
JavaScript/Node.js
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/v1already on the end,https://license-cloud-api.casazium.com/v1, so that value replaceshttps://your-license-api.com/v1as a whole: don't add a second/v1. Notlicense-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 acceptADMIN_API_KEY. - Self-hosted: your own server's address. The self-hosted bundle answers on
http://localhost:3001on the machine running it, sohttp://localhost:3001/v1replaceshttps://your-license-api.com/v1.
With the SDK
In JavaScript, use @casazium/license-sdk. It's an ES module, so run this as one (a .mjs file, or "type": "module" in your package.json):
npm install @casazium/license-sdkimport { CasaziumLicenseClient } from '@casazium/license-sdk';
const client = new CasaziumLicenseClient({
baseUrl: 'https://your-license-api.com/v1',
});
const key = process.env.LICENSE_KEY;
const instanceId = 'server-001'; // the same on every start of this instance
// Throws if the key is refused or belongs to another product.
const license = await client.verifyKey(key, 'your-product-uuid');
// Takes a seat for this instance; keep `token` to deactivate it later.
const { token } = await client.activate(key, instanceId);Using the SDK lists every method and how errors work. The two examples below build an application on it: a service that takes a seat while it runs and reports usage, and Express middleware that enforces the license on each request.
Complete Integration Example
Uses @casazium/license-sdk 0.4.0 or later (npm install @casazium/license-sdk) instead of hand-rolled fetch() calls — it covers verify/activate/deactivate/usage-tracking directly, so this example is now a thin application-level wrapper (instance-ID generation, periodic usage batching, graceful shutdown) around the SDK's own client, not a reimplementation of the HTTP calls themselves.
The SDK's methods throw when the server says no — an unknown, revoked or expired key, a key for another product, no seats left — so there's no valid: false to check on what they return. From 0.4.0 the error carries the HTTP status on err.status; a network failure or timeout has none. isRefusal() below turns that into the one question that matters: did the server answer no, or was there no answer?
import { CasaziumLicenseClient } from '@casazium/license-sdk';
import os from 'os';
import crypto from 'crypto';
// true when the server answered no: a 4xx other than 429, or the SDK's own
// refusal of a key for another product. false when there was no usable
// answer - a network error or timeout (no status), 429 (slow down), a 5xx,
// or a 404 for a route the server doesn't have.
function isRefusal(error) {
if (error.status === undefined) {
return error.message.includes('product_uuid mismatch');
}
// 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 (error.status === 404 && /: Not Found$/.test(error.message)) return false;
return error.status >= 400 && error.status < 500 && error.status !== 429;
}
class LicenseManager {
constructor(licenseKey, apiUrl, productUuid, options = {}) {
this.licenseKey = licenseKey;
this.client = new CasaziumLicenseClient({
baseUrl: apiUrl,
// The SDK sets no timeout of its own: give up on a server that
// doesn't answer, rather than hang a startup or a request.
fetch: (url, init) => fetch(url, { ...init, signal: AbortSignal.timeout(10000) })
});
// Your own product's product_uuid - get it
// once from your first issue-license/register-release admin call and
// hard-code it; it never changes for a given product_id under your
// own tenant. Required by verifyKey() below.
this.productUuid = productUuid;
this.instanceId = options.instanceId || this.generateInstanceId();
this.isActivated = false;
this.licenseInfo = null;
this.usageTracker = null;
this.token = null;
}
generateInstanceId() {
const hostname = os.hostname();
const platform = os.platform();
return crypto
.createHash('sha256')
.update(`${hostname}-${platform}-${Date.now()}`)
.digest('hex')
.substring(0, 16);
}
async initialize() {
try {
// Is the license good for this product? Throws if not, so reaching
// the next line means it is.
const verification = await this.client.verifyKey(this.licenseKey, this.productUuid);
// Take a seat for this process. Throws if refused - for example,
// when every seat is taken.
const activation = await this.client.activate(this.licenseKey, this.instanceId);
// `token` is only present on the call that creates the
// activation — an already-activated replay omits it, so don't
// overwrite a previously-captured token with undefined. It's
// the credential shutdown() needs to deactivate this instance.
this.token = activation.token || this.token;
if (!this.token) {
// This instanceId was activated before and its token is gone (an
// instanceId passed in options, but the token wasn't kept). The
// server won't send it again: free the seat (the license portal,
// or POST /admin/deactivate-by-instance-id) and start again.
throw new Error("Already activated here, but this instance's activation token wasn't kept");
}
this.isActivated = true;
this.licenseInfo = verification;
// Start usage tracking
this.startUsageTracking();
console.log('License initialized successfully');
return true;
} catch (error) {
// Refused (isRefusal(error)) or unreachable, this process can't take
// a seat, so it doesn't start. error.message says which.
console.error('License initialization failed:', error.message);
return false;
}
}
// Reports one metric per call -- `metric` must already be a key in this
// license's `limits` (see the Usage Tracking guide for the full list of
// allowed metric names). `increment` defaults to 1 if omitted. Requires
// this.token (the activation token captured in initialize()) -- the
// license key alone is not accepted.
async trackUsage(metric, increment = 1) {
return this.client.trackUsage(this.licenseKey, this.token, metric, increment);
}
// Returns a live snapshot of every metric this license has limits for:
// { valid, status, expires_at, metrics: [{metric, used, limit, remaining, exceeded}] }
async getUsageReport() {
return this.client.getUsageReport(this.licenseKey);
}
// track-usage adds to a running count per license that never resets by
// itself, so it meters things that accumulate (API calls), not a level
// such as current sessions.
startUsageTracking() {
this.usageTracker = { apiCalls: 0 };
// Report usage every 5 minutes
this.usageTimer = setInterval(async () => {
const calls = this.usageTracker.apiCalls;
if (calls === 0) return;
try {
await this.trackUsage('api_calls_per_day', calls);
// Only what was reported: calls recorded meanwhile stay for the
// next report.
this.usageTracker.apiCalls -= calls;
} catch (error) {
console.warn('Usage tracking failed:', error.message);
if (!isRefusal(error) && error.name !== 'TimeoutError') {
return; // a 429, 5xx or no connection: not counted, try next time
}
// A refusal won't change, and after a timeout the server may have
// counted them already: better to undercount than to count twice.
this.usageTracker.apiCalls -= calls;
if (/Usage limit exceeded/.test(error.message)) {
await this.reportWhatFits('api_calls_per_day');
}
}
}, 300000);
}
// The server refuses the whole increment when it would pass the limit.
// Send the part that still fits, so usage reaches the limit and
// usage-report shows nothing remaining; the rest is over it.
async reportWhatFits(metric) {
try {
const report = await this.getUsageReport();
const remaining = report.metrics.find((m) => m.metric === metric)?.remaining;
if (remaining > 0) await this.trackUsage(metric, remaining);
} catch (error) {
console.warn('Usage limit reached:', error.message);
}
}
recordAPICall() {
if (this.usageTracker) {
this.usageTracker.apiCalls++;
}
}
async shutdown() {
clearInterval(this.usageTimer);
if (this.isActivated) {
// Send the last usage while the token still works.
const calls = this.usageTracker?.apiCalls ?? 0;
if (calls > 0) {
await this.trackUsage('api_calls_per_day', calls).catch((error) =>
console.warn('Final usage report failed:', error.message));
}
try {
const result = await this.client.deactivate(this.licenseKey, this.token);
this.isActivated = false;
if (result.deactivated) {
console.log('License deactivated successfully');
} else {
// No activation matched the token: the seat was already freed.
console.log('No activation to deactivate (already freed)');
}
} catch (error) {
console.error('License deactivation failed:', error.message);
}
}
}
}
// Usage
const licenseClient = new LicenseManager(
process.env.LICENSE_KEY,
'https://your-license-api.com/v1',
'b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8' // your own product_uuid
);
licenseClient.initialize().then(success => {
if (success) {
console.log('Application ready');
// Your application logic here
licenseClient.recordAPICall();
// Graceful shutdown - SIGTERM is what a container orchestrator sends
// (docker stop, a Kubernetes pod termination); SIGINT is what a local
// Ctrl-C sends. Both need to deactivate: there's no automatic
// expiration on an activation, so a process that exits without either
// handler firing leaves its seat permanently occupied until it's
// manually freed via POST /admin/deactivate-by-instance-id.
//
// Registering a SIGINT handler replaces Node's default "Ctrl-C exits
// immediately" behavior, so a hung deactivate call (API unreachable,
// or still retrying) would otherwise leave the process impossible to
// interrupt. shuttingDown makes a second signal force an immediate
// exit, and the timeout below caps how long the first one waits.
let shuttingDown = false;
const shutdown = () => {
if (shuttingDown) {
process.exit(1);
}
shuttingDown = true;
const forceExit = setTimeout(() => process.exit(1), 5000).unref();
licenseClient.shutdown().finally(() => {
clearTimeout(forceExit);
process.exit(0);
});
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
} else {
process.exit(1);
}
});Express.js Middleware
import express from 'express';
class LicenseMiddleware {
constructor(licenseClient) {
this.licenseClient = licenseClient;
// enforceLimits()'s last decision: null to let requests through, or
// the response to block them with.
this.checkedAt = 0;
this.blocked = null;
}
// Middleware to track API calls
trackAPICalls() {
return (req, res, next) => {
this.licenseClient.recordAPICall();
next();
};
}
// Middleware to enforce the license and its limits.
//
// verifyKey() checks the license itself, and returns its terms (`limits`
// among them) but not how much has been used - that comes from
// usage-report (getUsageReport()). validate() checks that this process is
// still activated: its seat can be freed elsewhere (the license portal, or
// POST /admin/deactivate-by-instance-id), and verifyKey() alone would
// keep passing. isRefusal() is from the example above.
//
// A check is three requests, and the validation endpoints allow 1000 per
// 15 minutes (Rate Limits), so the decision is reused for a minute rather
// than made on every request.
enforceLimits() {
return async (req, res, next) => {
if (Date.now() - this.checkedAt >= 60_000) {
this.checkedAt = Date.now(); // before the await: one check at a time
this.blocked = await this.checkLicense();
}
if (this.blocked) {
return res.status(this.blocked.status).json(this.blocked.body);
}
next();
};
}
// null when requests may go through; otherwise the response to block
// them with.
async checkLicense() {
const { client, licenseKey, productUuid, instanceId } = this.licenseClient;
try {
await client.verifyKey(licenseKey, productUuid); // throws if refused
const { valid } = await client.validate(licenseKey, instanceId);
if (!valid) {
return {
status: 403,
body: {
error: 'License not activated',
message: 'This instance is no longer activated on the license'
}
};
}
// The metered metric's limit is used up once nothing remains: the
// server never lets usage go past the limit, so `exceeded` alone
// would never block.
const report = await this.licenseClient.getUsageReport();
const metered = report.metrics.find((m) => m.metric === 'api_calls_per_day');
if (metered && metered.remaining <= 0) {
return {
status: 429,
body: {
error: 'Usage limit reached',
limit: metered.metric,
current: metered.used,
max: metered.limit
}
};
}
return null;
} catch (error) {
if (isRefusal(error)) {
// The server said no: revoked, expired, unknown, or another
// product's key.
return {
status: 403,
body: {
error: 'License validation failed',
message: 'Please check your license status'
}
};
}
// No answer from the license server: don't take your own service
// down with it.
console.warn('License check unavailable:', error.message);
return null;
}
}
}
// Usage
const app = express();
const licenseMiddleware = new LicenseMiddleware(licenseClient);
// Apply middleware
app.use(licenseMiddleware.trackAPICalls());
app.use('/api', licenseMiddleware.enforceLimits());
app.get('/api/data', (req, res) => {
res.json({ data: 'Your protected data' });
});
app.listen(3000, () => {
console.log('Server running with license protection');
});Without the SDK (curl or any language)
To make the calls yourself, with fetch or another HTTP client:
- the Integration overview has a complete Node.js license manager that calls the API directly;
- License Activation, License Verification and Usage Tracking show each call with curl, and in JavaScript;
- Registering a Release and Auto-Update Checking, below, include Node.js without the SDK.
The Python, Java and Go examples below call the API directly too.
Python
Complete Integration Example
import requests
import threading
import time
import hashlib
import os
import uuid
import platform
import json
import base64
from typing import Dict, Any, Optional
class LicenseServerUnavailable(Exception):
"""No usable answer from the license server: a network error, a 429
(slow down) or a 5xx. Not a refusal - don't treat the license as
invalid because of it."""
def answer(response: requests.Response) -> Dict[str, Any]:
"""The JSON body of a license-server response, or LicenseServerUnavailable
for a 429 or 5xx. A 4xx other than 429 is an answer (the server said
no) and comes back as its body, e.g. {'valid': False, 'error': ...}."""
if response.status_code == 429 or response.status_code >= 500:
raise LicenseServerUnavailable(f'license server answered {response.status_code}')
return response.json()
class LicenseClient:
def __init__(self, license_key: str, api_url: str, product_uuid: str,
instance_id: Optional[str] = None):
self.license_key = license_key
self.api_url = api_url.rstrip('/')
# Your own product's product_uuid - get it once from your first
# issue-license/register-release admin call and hard-code it. A
# License Server can hold keys for many products, so without this
# check a valid key for any other product would pass.
self.product_uuid = product_uuid
self.instance_id = instance_id or self.generate_instance_id()
self.is_activated = False
self.is_valid = False # the last answer: False once the server refuses
self.license_info = None
self.usage_tracker = None
self.usage_lock = threading.Lock()
self.validation_thread = None
self.tracking_thread = None
self.is_running = False
self.token = None
def generate_instance_id(self) -> str:
"""A new identifier for this process: two processes on one host
(gunicorn workers, a quick restart) each get their own."""
hostname = platform.node()
unique_string = f"{hostname}-{os.getpid()}-{uuid.uuid4()}"
return hashlib.sha256(unique_string.encode()).hexdigest()[:16]
def initialize(self) -> bool:
"""Initialize license verification and activation."""
try:
# Verify license
verification = self.verify_license()
if not verification.get('valid'):
raise Exception(f"Invalid license: {verification.get('error', 'Unknown error')}")
# Activate license
activation = self.activate_license()
if not activation.get('activated'):
raise Exception(f"Activation failed: {activation.get('error', 'Unknown error')}")
# `token` is only present on the call that creates the
# activation — an already-activated replay omits it, so
# don't overwrite a previously-captured token with None.
# It's the credential shutdown() and track_usage() need.
self.token = activation.get('token') or self.token
if not self.token:
# This instance_id was activated before and its token is
# gone (a stable instance_id, but the token wasn't kept).
# The server won't send it again: free the seat (the
# license portal, or POST /admin/deactivate-by-instance-id)
# and start again. Save the token alongside any stable
# instance_id you pass in.
raise Exception("Already activated here, but this instance's activation token wasn't kept")
self.is_activated = True
self.is_valid = True
self.license_info = verification
# Start background tasks
self.start_background_tasks()
print("License initialized successfully")
return True
except Exception as e:
print(f"License initialization failed: {e}")
return False
def verify_license(self) -> Dict[str, Any]:
"""Verify license key validity, for this product."""
response = requests.post(
f'{self.api_url}/verify-license',
json={'key': self.license_key},
timeout=30
)
result = answer(response)
# The same check the JavaScript SDK's verifyKey() makes.
if result.get('valid') and result.get('product_uuid') != self.product_uuid:
return {'valid': False, 'error': 'License belongs to a different product'}
return result
def activate_license(self) -> Dict[str, Any]:
"""Activate license for this instance. Only key/instance_id are
accepted -- the server has no instance_info field."""
response = requests.post(
f'{self.api_url}/activate-license',
json={
'key': self.license_key,
'instance_id': self.instance_id
},
timeout=30
)
return answer(response)
def validate_license(self) -> Dict[str, Any]:
"""Validate current license status."""
response = requests.post(
f'{self.api_url}/validate-license',
json={
'key': self.license_key,
'instance_id': self.instance_id
},
timeout=30
)
return answer(response)
def track_usage(self, metric: str, increment: int = 1) -> Dict[str, Any]:
"""Report usage for a single metric. `metric` must already be a key
in this license's `limits`. Requires self.token (the activation
token captured in initialize()) -- the license key alone is not
accepted."""
response = requests.post(
f'{self.api_url}/track-usage',
json={
'key': self.license_key,
'token': self.token,
'metric': metric,
'increment': increment
},
timeout=30
)
result = answer(response)
if not response.ok:
# e.g. "Usage limit exceeded for metric: ..." - the increment
# was not applied.
raise Exception(f"track-usage refused: {result.get('error')}")
return result
def get_usage_report(self) -> Dict[str, Any]:
"""Live snapshot of every metric this license has limits for."""
response = requests.post(
f'{self.api_url}/usage-report',
json={'key': self.license_key},
timeout=30
)
return answer(response)
def get_public_key(self) -> str:
"""Fetch the RSA public key used to verify offline-signed license
files (see export_offline_license()). Intended to be fetched once
and cached/embedded by the consuming application -- not something
a genuinely offline client should call on every verification."""
response = requests.get(f'{self.api_url}/public-key', timeout=30)
return response.json()['publicKey']
def export_offline_license(self, key: Optional[str] = None) -> Dict[str, Any]:
"""Fetch a license payload RSA-signed with the server's private
key, in the {license, signature} shape verify_signed_license()
expects. Unlike verify_license()/validate_license(), the returned
signature can be checked with only the public key from
get_public_key() -- no further server contact required."""
# License Server 1.21.0+: the key goes in the body, not the URL.
response = requests.post(
f'{self.api_url}/license-export/offline',
json={'key': key or self.license_key},
timeout=30
)
result = answer(response)
if not response.ok:
# A revoked or expired license can't be exported.
raise Exception(f"export refused: {result.get('error')}")
return result
def start_background_tasks(self):
"""Start background validation and usage tracking."""
self.is_running = True
self.usage_tracker = {
'api_calls': 0,
'start_time': time.time()
}
# Start validation thread
self.validation_thread = threading.Thread(target=self._validation_loop)
self.validation_thread.daemon = True
self.validation_thread.start()
# Start tracking thread
self.tracking_thread = threading.Thread(target=self._tracking_loop)
self.tracking_thread.daemon = True
self.tracking_thread.start()
def _validation_loop(self):
"""Background license validation."""
while self.is_running:
try:
# validate_license() checks this instance is still
# activated: {'valid': False} if the license was revoked,
# expired or deleted, or this instance's seat was freed.
validation = self.validate_license()
if not validation.get('valid'):
print("License is no longer valid for this instance")
self._handle_invalid_license()
break
time.sleep(3600) # Check every hour
except Exception as e:
# LicenseServerUnavailable or a network error: no answer,
# so keep the last one and ask again later.
print(f"License check unavailable: {e}")
time.sleep(300) # Retry in 5 minutes
def _tracking_loop(self):
"""Background usage tracking. track-usage adds to a running count
per license that never resets by itself, so it meters things that
accumulate (API calls), not a level such as current sessions."""
while self.is_running:
with self.usage_lock:
calls = self.usage_tracker['api_calls'] if self.usage_tracker else 0
try:
if calls > 0:
self.track_usage('api_calls_per_day', calls)
# Only what was reported: calls recorded meanwhile
# stay for the next report.
with self.usage_lock:
self.usage_tracker['api_calls'] -= calls
except (LicenseServerUnavailable, requests.ConnectionError) as e:
# A 429 or 5xx, or no connection: not counted, so the
# calls stay for the next report.
print(f"Usage tracking unavailable: {e}")
except Exception as e:
# Refused, which won't change; or a timeout, after which
# the server may have counted them already - better to
# undercount than to count twice.
with self.usage_lock:
self.usage_tracker['api_calls'] -= calls
print(f"Usage report not sent, dropping {calls}: {e}")
if 'Usage limit exceeded' in str(e):
self._report_what_fits('api_calls_per_day')
time.sleep(300) # Report every 5 minutes
def _report_what_fits(self, metric: str):
"""The server refuses the whole increment when it would pass the
limit. Send the part that still fits, so usage reaches the limit
and usage-report shows nothing remaining; the rest is over it."""
try:
report = self.get_usage_report()
remaining = next((m['remaining'] for m in report.get('metrics', [])
if m['metric'] == metric), 0)
if remaining > 0:
self.track_usage(metric, remaining)
except Exception as e:
print(f"Usage limit reached: {e}")
def _handle_invalid_license(self):
"""Handle invalid license scenario."""
print("License is invalid - shutting down application")
self.is_valid = False
self.shutdown()
# In a real application, you might want to exit or disable features
def record_api_call(self):
"""Record an API call."""
if self.usage_tracker:
with self.usage_lock:
self.usage_tracker['api_calls'] += 1
def shutdown(self):
"""Shutdown license client and deactivate."""
print("Shutting down license client...")
self.is_running = False
if self.is_activated:
# Send the last usage while the token still works.
with self.usage_lock:
calls = self.usage_tracker['api_calls'] if self.usage_tracker else 0
if calls > 0:
try:
self.track_usage('api_calls_per_day', calls)
except Exception as e:
print(f"Final usage report failed: {e}")
try:
response = requests.post(
f'{self.api_url}/deactivate-license',
json={
'key': self.license_key,
'token': self.token
},
timeout=30
)
result = answer(response)
if result.get('deactivated'):
print("License deactivated successfully")
self.is_activated = False
elif response.ok:
# No activation matched the token: already freed.
print("No activation to deactivate (already freed)")
self.is_activated = False
else:
print(f"License not deactivated: {result.get('error', result)}")
except Exception as e:
print(f"License deactivation failed: {e}")
def verify_signed_license(
license: Dict[str, Any], signature_base64: str, public_key_pem: str, expected_product_uuid: str
) -> bool:
"""Verify a license from export_offline_license() using only the
public key from get_public_key() -- no server contact required, works
fully offline. True only when all four hold, as the JavaScript SDK's
verifySignedFile() checks:
- the RSA-SHA256 signature (base64, PKCS#1 v1.5 -- Node's
createSign('SHA256') defaults) is the server's, over these bytes;
- it's a license (`type` is "license"): release manifests are signed
with the same key, and one must not pass as a license;
- `product_uuid` is your product's -- see "Product IDs are
per-tenant, not global" in the API reference;
- it hasn't expired: `expires_at` is None (perpetual) or a date in the
future; one that won't parse is refused, not read as perpetual.
A file exported before License Server 1.10.0 has no `type`: export it
again. Requires the `cryptography` package (not needed for the rest of
this client): pip install cryptography
"""
from datetime import datetime, timezone
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
# Must match the server's JSON.stringify(license) byte-for-byte: no
# extra whitespace, and key order exactly as parsed from the response
# (Python's json module preserves insertion order, same as JS).
data = json.dumps(license, separators=(',', ':'), ensure_ascii=False).encode('utf-8')
try:
public_key = serialization.load_pem_public_key(public_key_pem.encode('utf-8'))
public_key.verify(base64.b64decode(signature_base64), data, padding.PKCS1v15(), hashes.SHA256())
except (InvalidSignature, ValueError, TypeError):
return False
if (license.get('type') != 'license' or not isinstance(license.get('key'), str)
or license.get('product_uuid') != expected_product_uuid):
return False
expires_at = license.get('expires_at', 'missing')
if expires_at is None:
return True
try:
expiry = datetime.fromisoformat(str(expires_at).replace('Z', '+00:00'))
except ValueError:
return False
return expiry > datetime.now(timezone.utc)
# Usage example
def main():
import os
import signal
license_client = LicenseClient(
os.getenv('LICENSE_KEY'),
'https://your-license-api.com/v1',
'b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8' # your own product_uuid
)
if license_client.initialize():
print("Application starting with valid license")
# SIGTERM is what a container orchestrator sends (docker stop, a
# Kubernetes pod termination); Python's default handling for it is
# immediate termination, unlike SIGINT/Ctrl-C, which already
# raises KeyboardInterrupt automatically. Re-raising it the same
# way routes both through the one shutdown path below - there's
# no automatic expiration on an activation, so a process that
# exits via SIGTERM without this handler leaves its seat
# permanently occupied until it's manually freed via
# POST /admin/deactivate-by-instance-id.
def handle_sigterm(signum, frame):
raise KeyboardInterrupt()
signal.signal(signal.SIGTERM, handle_sigterm)
try:
# Simulate application work
while True:
license_client.record_api_call()
time.sleep(1)
except KeyboardInterrupt:
print("\nShutdown requested...")
license_client.shutdown()
else:
print("Failed to initialize license - exiting")
exit(1)
if __name__ == "__main__":
main()Offline License Verification
Fetch the public key and a signed license once, then verify with no further server contact -- useful for desktop/CLI tools that need to check license authenticity without a live connection. Requires the optional cryptography package (pip install cryptography); nothing else in LicenseClient needs it.
license_client = LicenseClient(os.getenv('LICENSE_KEY'), 'https://your-license-api.com/v1', YOUR_PRODUCT_UUID)
public_key = license_client.get_public_key() # fetch once, cache/embed it
license_data = license_client.export_offline_license()
if verify_signed_license(license_data['license'], license_data['signature'], public_key, YOUR_PRODUCT_UUID):
print(f"License valid: {license_data['license']['tier']} tier")
else:
print("Not a valid, unexpired license for this product -- do not trust this file")Pass license_data['license'] exactly as parsed from the response. From License Server 1.20.0 the file also signs issued_at, exported_at, livemode and paid_through after limits, and later releases may add more: a dict rebuilt from the fields you know, or one with sorted keys, won't verify. Read the new fields with .get(), since older servers send none of them (see What the file signs).
Flask Integration
from flask import Flask, request, jsonify, g
from functools import wraps
app = Flask(__name__)
license_client = None # Initialize with your license client
class LicenseFlaskIntegration:
def __init__(self, license_client):
self.license_client = license_client
def require_valid_license(self, f):
"""Decorator to require valid license for routes. Reads the state
the client's hourly background check keeps (is_valid) rather than
calling the server on every request: the validation endpoints
allow 1000 requests per 15 minutes, and a busy app would use them
up, then refuse its users."""
@wraps(f)
def decorated_function(*args, **kwargs):
if not (self.license_client and self.license_client.is_valid):
return jsonify({
'error': 'Invalid license',
'message': 'License validation failed'
}), 403
# tier/expires_at/etc. come from the verify_license() call made
# during initialize(), cached on the client.
g.license_info = self.license_client.license_info
return f(*args, **kwargs)
return decorated_function
def track_request(self):
"""Track incoming requests."""
self.license_client.record_api_call()
# The client is set in __main__ below, before the app starts.
license_integration = LicenseFlaskIntegration(None)
@app.before_request
def before_request():
if license_client and license_client.is_activated:
license_integration.track_request()
@app.route('/api/protected-data')
@license_integration.require_valid_license
def get_protected_data():
return jsonify({
'data': 'This is protected data',
'license_tier': g.license_info.get('tier')
})
@app.route('/api/license-status')
def license_status():
if not license_client or not license_client.is_activated:
return jsonify({'status': 'not_activated'})
return jsonify({
'status': 'active' if license_client.is_valid else 'invalid',
'tier': license_client.license_info.get('tier'),
'expires_at': license_client.license_info.get('expires_at')
})
if __name__ == '__main__':
import atexit
import os
import signal
# Initialize license client
license_client = LicenseClient(
os.getenv('LICENSE_KEY'),
'https://your-license-api.com/v1',
'b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8' # your own product_uuid
)
license_integration.license_client = license_client
if license_client.initialize():
# Free the seat when the process ends: there's no automatic
# expiration on an activation (see the complete example above).
atexit.register(license_client.shutdown)
signal.signal(signal.SIGTERM, lambda signum, frame: exit(0))
print("Starting Flask app with license protection")
app.run(debug=False, host='0.0.0.0', port=5000)
else:
print("Failed to initialize license - exiting")
exit(1)Software Distribution: registering releases and checking for updates
Two standalone functions, not part of LicenseClient above. register_release() is admin/tenant-key gated — call it from your own CI or release pipeline, never from an end-user app; see Registering a Release for the curl/Node equivalent and every error code. check_update()/verify_release() are what an end-user app calls, mirroring the raw JS example in Auto-Update Checking — there's no published Python package for this SDK (only @casazium/license-sdk on npm is a real installable package; everything on this page is a reference implementation to copy into your own code), so this is the only way to call either endpoint from Python.
import requests
import json
import base64
import os
from typing import Any, Dict
def register_release(api_url: str, admin_api_key: str, release: Dict[str, Any]) -> Dict[str, Any]:
"""Register a new release. Admin/tenant-key gated -- call this from
your CI/release pipeline, never from an end-user app."""
response = requests.post(
f'{api_url}/register-release',
headers={'Authorization': f'Bearer {admin_api_key}'},
json=release,
timeout=30,
)
if not response.ok:
try:
error = response.json().get('error')
except ValueError:
error = None
raise Exception(f"register-release failed with {response.status_code}" + (f": {error}" if error else ""))
return response.json() # {id, status, product_uuid}
def check_update(api_url: str, license_key: str, channel: str, platform: str, current_version: str) -> Dict[str, Any]:
"""Public, license-key-gated -- safe to call from an end-user app on
every launch. {update_available: False, release: None} means
nothing newer is published; not an error."""
response = requests.post(
f'{api_url}/check-update',
json={
'key': license_key,
'channel': channel,
'platform': platform,
'current_version': current_version,
},
timeout=30,
)
if not response.ok:
raise Exception(f"check-update failed with {response.status_code}")
return response.json()
def verify_release(release: Dict[str, Any], public_key_pem: str, expected_product_uuid: str) -> bool:
"""Same RSA/SHA256-over-JSON.stringify() scheme as
verify_signed_license() above, applied to a check-update manifest
instead of an exported license. Field order matters: pass `release`
straight from response.json() rather than rebuilding it yourself --
Python's json module preserves the server's own key order, and the
signature only verifies against that exact order with `signature`
itself removed.
Authenticity alone doesn't prove this manifest is for *your*
product -- checking product_uuid explicitly is real defense-in-
depth, not just a discoverability nicety (see "Product IDs are
per-tenant, not global" in the API reference).
Requires the `cryptography` package: pip install cryptography
"""
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
manifest = {k: v for k, v in release.items() if k != 'signature'}
public_key = serialization.load_pem_public_key(public_key_pem.encode('utf-8'))
signature = base64.b64decode(release['signature'])
data = json.dumps(manifest, separators=(',', ':'), ensure_ascii=False).encode('utf-8')
try:
public_key.verify(signature, data, padding.PKCS1v15(), hashes.SHA256())
except InvalidSignature:
return False
return manifest.get('product_uuid') == expected_product_uuid
# Usage
API_URL = 'https://your-license-api.com/v1'
MY_PRODUCT_UUID = 'b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8' # your own product_uuid, hard-coded once
result = check_update(API_URL, os.getenv('LICENSE_KEY'), 'stable', 'darwin-arm64', APP_VERSION)
if result['update_available']:
release = result['release']
public_key = requests.get(f'{API_URL}/public-key', timeout=30).json()['publicKey']
if not verify_release(release, public_key, MY_PRODUCT_UUID):
raise Exception('Update signature invalid or for the wrong product -- refusing to trust this release')
print(f"Update available: {release['version']}", release['artifact_url'])
# Fetch release['artifact_url'] yourself and confirm it hashes to
# release['checksum'] before running/installing anything from it.Java
Complete Integration Example
LicenseClient takes your own product's product_uuid and refuses a key for any other product. Every call throws LicenseApiException for a non-2xx answer; its isRefusal() asks the question the JavaScript example's does: did the server answer no (a 4xx other than 429, or a key for another product), or was there no answer (429, a 5xx, or a network failure or timeout, which throws IOException)? The background check gives up the seat only on a no; when there's no answer, it keeps running and tries again. One difference: the JavaScript SDK example also treats a 404 the server sends for a route it doesn't have (error: "Not Found", for example a base URL missing /v1) as no answer, while this isRefusal() counts every 404 as a no. If you add a call to the POST export routes, a server older than 1.21.0 answers it with that 404 too.
// LicenseClient.java - Main below isn't public, so both classes fit in
// this one file (run with `java Main`); split them if you prefer.
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
public class LicenseClient {
// Thrown for any non-2xx answer, and for a key that belongs to another
// product. isRefusal() is the one question that matters: did the server
// answer no (a 4xx other than 429, or a product mismatch), or was there
// no usable answer (429 = slow down, or a 5xx)? A network failure or
// timeout is an IOException instead - also "no answer".
public static class LicenseApiException extends Exception {
private final int status;
private final boolean refusal;
LicenseApiException(String message, int status, boolean refusal) {
super(message);
this.status = status;
this.refusal = refusal;
}
public int getStatus() { return status; }
public boolean isRefusal() { return refusal; }
}
private final String licenseKey;
private final String apiUrl;
// Your own product's product_uuid - get it once from your first
// issue-license/register-release admin call and hard-code it. Without
// this check, a key for any other product on the same server passes.
private final String productUuid;
private final String instanceId;
private final HttpClient httpClient;
private final ObjectMapper objectMapper;
private final ScheduledExecutorService scheduler;
private final AtomicInteger apiCalls = new AtomicInteger(0);
private final AtomicBoolean shutDown = new AtomicBoolean(false);
private volatile boolean isActivated = false;
private volatile JsonNode licenseInfo;
private volatile String token;
public LicenseClient(String licenseKey, String apiUrl, String productUuid) {
this.licenseKey = licenseKey;
this.apiUrl = apiUrl.replaceAll("/$", "");
this.productUuid = productUuid;
this.instanceId = generateInstanceId();
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30))
.build();
this.objectMapper = new ObjectMapper();
this.scheduler = Executors.newScheduledThreadPool(2);
}
private String generateInstanceId() {
try {
String hostname = java.net.InetAddress.getLocalHost().getHostName();
String os = System.getProperty("os.name");
String timestamp = String.valueOf(System.currentTimeMillis());
String combined = hostname + "-" + os + "-" + timestamp;
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(combined.getBytes(java.nio.charset.StandardCharsets.UTF_8));
StringBuilder hexString = new StringBuilder();
for (int i = 0; i < Math.min(8, hash.length); i++) {
String hex = Integer.toHexString(0xff & hash[i]);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
} catch (Exception e) {
return "java-instance-" + System.currentTimeMillis();
}
}
public boolean initialize() {
try {
// Is the license good for this product? Throws if not, so
// reaching the next line means it is.
JsonNode verification = verifyLicense();
// Take a seat for this process. Throws if refused - for
// example, when every seat is taken.
JsonNode activation = activateLicense();
// "token" is only present on the call that creates the
// activation - an already-activated replay omits it. Keep a
// token captured earlier in this process; with none at all,
// this instance holds a seat it can never report usage
// against or deactivate, so don't start on it.
if (activation.hasNonNull("token")) {
this.token = activation.get("token").asText();
} else if (this.token == null) {
throw new IllegalStateException("instance " + instanceId
+ " already holds a seat but its activation token is lost - free the seat"
+ " (license portal, or POST /admin/deactivate-by-instance-id) and start again");
}
this.isActivated = true;
this.licenseInfo = verification;
// Start background tasks
startBackgroundTasks();
System.out.println("License initialized successfully");
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} catch (Exception e) {
// Refused (LicenseApiException.isRefusal()) or unreachable,
// this process can't take a seat, so it doesn't start.
System.err.println("License initialization failed: " + e.getMessage());
return false;
}
}
// POSTs a JSON body and returns the parsed response for a 2xx; throws
// LicenseApiException for anything else, IOException when there was
// no response at all.
private JsonNode post(String path, Map<String, ?> body)
throws IOException, InterruptedException, LicenseApiException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiUrl + path))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(30))
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString());
int status = response.statusCode();
JsonNode json;
try {
json = objectMapper.readTree(response.body());
} catch (JsonProcessingException e) {
json = null; // e.g. an HTML error page from a proxy
}
if (json == null) {
json = objectMapper.createObjectNode();
}
if (status < 200 || status >= 300) {
String error = json.path("error").asText(null);
boolean refusal = status >= 400 && status < 500 && status != 429;
throw new LicenseApiException(path + " failed with " + status
+ (error != null ? ": " + error : ""), status, refusal);
}
return json;
}
public JsonNode verifyLicense()
throws IOException, InterruptedException, LicenseApiException {
// 404 (unknown key) and 403 (revoked, inactive or expired) throw
// from post(); a 200 is always {valid: true, ...}.
JsonNode verification = post("/verify-license", Map.of("key", licenseKey));
if (!productUuid.equals(verification.path("product_uuid").asText(null))) {
throw new LicenseApiException(
"verify-license: license belongs to a different product (product_uuid mismatch)",
200, true);
}
return verification;
}
public JsonNode activateLicense()
throws IOException, InterruptedException, LicenseApiException {
// Only key/instance_id are accepted -- the server has no
// instance_info field.
return post("/activate-license", Map.of(
"key", licenseKey,
"instance_id", instanceId
));
}
// {valid: false} (revoked, expired, or this instance's seat was freed)
// comes back as a 200, not an error.
public JsonNode validateLicense()
throws IOException, InterruptedException, LicenseApiException {
return post("/validate-license", Map.of(
"key", licenseKey,
"instance_id", instanceId
));
}
// Reports one metric per call -- `metric` must already be a key in
// this license's `limits`. Requires the `token` field captured in
// initialize() -- the license key alone is not accepted. Throws on a
// non-2xx, e.g. 403 "Usage limit exceeded for metric".
public JsonNode trackUsage(String metric, int increment)
throws IOException, InterruptedException, LicenseApiException {
return post("/track-usage", Map.of(
"key", licenseKey,
"token", token,
"metric", metric,
"increment", increment
));
}
private void startBackgroundTasks() {
// Start validation task (every hour)
scheduler.scheduleAtFixedRate(this::validateLicenseBackground,
1, 1, TimeUnit.HOURS);
// Start usage tracking task (every 5 minutes)
scheduler.scheduleAtFixedRate(this::reportUsage,
5, 5, TimeUnit.MINUTES);
}
private void validateLicenseBackground() {
try {
JsonNode validation = validateLicense();
if (!validation.path("valid").asBoolean(false)) {
System.err.println("License is no longer valid for this instance");
handleInvalidLicense();
}
} catch (LicenseApiException e) {
if (e.isRefusal()) {
System.err.println("License validation refused: " + e.getMessage());
handleInvalidLicense();
} else {
// 429 or 5xx: no answer, not a "no". Keep running and
// try again on the next run - never give up the seat
// over an outage.
System.err.println("License validation unavailable: " + e.getMessage());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
// Network error or timeout - also no answer.
System.err.println("License validation error: " + e.getMessage());
}
}
// Only api_calls_per_day is reported. track-usage keeps a running
// total that never resets by itself, so it meters things that
// accumulate - not a level such as concurrent sessions, which summed
// every 5 minutes would only ever grow until it hit the limit.
private void reportUsage() {
int count = apiCalls.getAndSet(0);
if (count == 0) {
return;
}
try {
trackUsage("api_calls_per_day", count);
} catch (LicenseApiException e) {
if (e.isRefusal()) {
// Metric not in this license's limits, or the limit is
// reached: resending won't change the answer.
System.err.println("Usage report refused, dropping " + count + ": " + e.getMessage());
if (e.getMessage().contains("Usage limit exceeded")) {
reportWhatFits("api_calls_per_day");
}
} else {
apiCalls.addAndGet(count); // 429 or 5xx, not counted: report it next time
System.err.println("Usage report unavailable: " + e.getMessage());
}
} catch (InterruptedException e) {
apiCalls.addAndGet(count);
Thread.currentThread().interrupt();
} catch (java.net.http.HttpTimeoutException e) {
// Timed out: the server may have counted it already, and
// resending would count it twice. Drop it - an undercount is
// the safer side.
System.err.println("Usage report timed out, dropping " + count);
} catch (IOException e) {
apiCalls.addAndGet(count); // no connection: not counted, report it next time
System.err.println("Usage reporting error: " + e.getMessage());
}
}
// The server refuses the whole increment when it would pass the limit.
// Send the part that still fits, so usage reaches the limit and
// usage-report shows nothing remaining; the rest is over it.
private void reportWhatFits(String metric) {
try {
JsonNode report = post("/usage-report", Map.of("key", licenseKey));
for (JsonNode m : report.path("metrics")) {
if (metric.equals(m.path("metric").asText()) && m.path("remaining").asInt(0) > 0) {
trackUsage(metric, m.path("remaining").asInt());
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
System.err.println("Usage limit reached: " + e.getMessage());
}
}
private void handleInvalidLicense() {
System.err.println("License is invalid - application should shut down");
shutdown();
// In a real application, exit or disable licensed features here.
}
public void recordApiCall() {
apiCalls.incrementAndGet();
}
// Safe to call more than once and from any thread, including a
// scheduler task (handleInvalidLicense) and the JVM shutdown hook:
// only the first call deactivates. scheduler.shutdown() just stops
// future runs - it doesn't wait on the pool, which would deadlock
// when called from one of the pool's own threads.
public void shutdown() {
if (!shutDown.compareAndSet(false, true)) {
return;
}
System.out.println("Shutting down license client...");
scheduler.shutdown();
if (isActivated) {
reportUsage(); // while the token still works
try {
deactivateLicense();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("License deactivation interrupted - seat " + instanceId + " may still be held");
} catch (Exception e) {
System.err.println("License deactivation failed, seat " + instanceId
+ " is still held (free it via the license portal or POST"
+ " /admin/deactivate-by-instance-id): " + e.getMessage());
}
isActivated = false;
}
}
private void deactivateLicense()
throws IOException, InterruptedException, LicenseApiException {
JsonNode result = post("/deactivate-license", Map.of(
"key", licenseKey,
"token", token
));
if (result.path("deactivated").asBoolean(false)) {
System.out.println("License deactivated successfully");
} else {
// 200 {deactivated: false}: no activation matched this
// token - the seat was already freed elsewhere.
System.out.println("No activation to deactivate (already freed)");
}
}
// Getters
public boolean isActivated() { return isActivated; }
public JsonNode getLicenseInfo() { return licenseInfo; }
public String getInstanceId() { return instanceId; }
}
// Usage example
class Main {
public static void main(String[] args) {
String licenseKey = System.getenv("LICENSE_KEY");
if (licenseKey == null) {
System.err.println("LICENSE_KEY environment variable not set");
System.exit(1);
}
LicenseClient licenseClient = new LicenseClient(
licenseKey,
"https://your-license-api.com/v1",
"b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8" // your own product_uuid
);
if (licenseClient.initialize()) {
System.out.println("Application starting with valid license");
// A shutdown hook runs on SIGTERM (docker stop, a Kubernetes
// pod termination) and Ctrl-C alike. There's no automatic
// expiration on an activation, so a process that exits
// without deactivating leaves its seat occupied until it's
// manually freed via POST /admin/deactivate-by-instance-id.
Runtime.getRuntime().addShutdownHook(new Thread(licenseClient::shutdown));
// Simulate application work
try {
while (true) {
licenseClient.recordApiCall();
Thread.sleep(1000);
}
} catch (InterruptedException e) {
System.out.println("Application interrupted");
}
} else {
System.err.println("Failed to initialize license - exiting");
System.exit(1);
}
}
}Software Distribution: registering releases and checking for updates
Requires jackson-databind 2.14 or later (which pulls in jackson-core and jackson-annotations transitively) - the same dependency already used above for JsonNode; 2.14 is the first version where verifyRelease() can make Jackson write JSON escapes the way JSON.stringify() does.
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.SignatureException;
import java.security.spec.X509EncodedKeySpec;
import java.time.Duration;
import java.util.Base64;
import java.util.Map;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.json.JsonWriteFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
public class ReleaseManagement {
private static final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30))
.build();
// verifyRelease() re-serializes the manifest and must reproduce the
// server's JSON.stringify() bytes exactly. Jackson escapes a control
// character other than \b \t \n \f \r with upper-case hex digits by
// default (ESC as 001B); JSON.stringify uses lower case (001b) - so a
// release_notes containing one (an ANSI escape, say) would fail
// verification. Lower-case hex matches it (WRITE_HEX_UPPER_CASE needs
// Jackson 2.14+).
private static final ObjectMapper objectMapper = JsonMapper.builder()
.disable(JsonWriteFeature.WRITE_HEX_UPPER_CASE)
.build();
private static JsonNode send(HttpRequest request, String label)
throws IOException, InterruptedException {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
int status = response.statusCode();
JsonNode result;
try {
result = objectMapper.readTree(response.body());
} catch (JsonProcessingException e) {
result = null; // e.g. an HTML error page from a proxy
}
if (status < 200 || status >= 300) {
String error = result == null ? null : result.path("error").asText(null);
throw new RuntimeException(label + " failed with " + status
+ (error != null ? ": " + error : ""));
}
if (result == null || result.isMissingNode()) {
throw new IOException(label + ": response was not JSON");
}
return result;
}
// Register a new release. Admin/tenant-key gated -- call this from
// your CI/release pipeline, never from an end-user app.
public static JsonNode registerRelease(String apiUrl, String adminApiKey, Map<String, ?> release)
throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiUrl + "/register-release"))
.header("Authorization", "Bearer " + adminApiKey)
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(30))
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(release)))
.build();
return send(request, "register-release"); // {id, status, product_uuid}
}
// Public, license-key-gated -- safe to call from an end-user app on
// every launch. {"update_available": false, "release": null} means
// nothing newer is published; not an error.
public static JsonNode checkUpdate(String apiUrl, String licenseKey, String channel, String platform,
String currentVersion) throws IOException, InterruptedException {
Map<String, String> body = Map.of(
"key", licenseKey,
"channel", channel,
"platform", platform,
"current_version", currentVersion
);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiUrl + "/check-update"))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(30))
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
return send(request, "check-update");
}
// The RSA public key that verifyRelease() needs. Fetch it once and
// cache or embed it.
public static String fetchPublicKey(String apiUrl) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(URI.create(apiUrl + "/public-key"))
.timeout(Duration.ofSeconds(30))
.GET()
.build();
return send(request, "public-key").path("publicKey").asText();
}
// Same RSA/SHA256-over-JSON.stringify() scheme as offline license
// verification, applied to a check-update manifest instead of an
// exported license. Field order matters: pass `release` straight from
// the parsed response rather than rebuilding it yourself -- Jackson's
// ObjectNode preserves the server's own key order, and the signature
// only verifies against that exact order with `signature` itself
// removed. The parameter is typed ObjectNode (not the more general
// JsonNode) specifically so deepCopy() below returns ObjectNode and
// .remove() can be called directly -- cast the value pulled out of the
// check-update response, as the usage example below does. False, not
// a throw, for a missing or malformed signature.
public static boolean verifyRelease(ObjectNode release, String publicKeyPem, String expectedProductUuid)
throws GeneralSecurityException, JsonProcessingException {
ObjectNode manifest = release.deepCopy();
JsonNode signatureNode = manifest.remove("signature");
if (signatureNode == null || !signatureNode.isTextual()) {
return false;
}
String publicKeyContent = publicKeyPem
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s", "");
byte[] keyBytes = Base64.getDecoder().decode(publicKeyContent);
PublicKey publicKey = KeyFactory.getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(keyBytes));
// Through a String, not writeValueAsBytes(): Jackson's byte writer
// escapes a character outside the Basic Multilingual Plane (an
// emoji, say) as two escaped surrogates, where JSON.stringify()
// writes the character itself.
byte[] data = objectMapper.writeValueAsString(manifest).getBytes(StandardCharsets.UTF_8);
try {
byte[] signatureBytes = Base64.getDecoder().decode(signatureNode.asText());
Signature verifier = Signature.getInstance("SHA256withRSA");
verifier.initVerify(publicKey);
verifier.update(data);
if (!verifier.verify(signatureBytes)) {
return false;
}
} catch (IllegalArgumentException | SignatureException e) {
return false; // not base64, or not a signature at all
}
return expectedProductUuid.equals(manifest.path("product_uuid").asText(null));
}
}import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
public class UpdateCheckExample {
static final String API_URL = "https://your-license-api.com/v1";
// Your own product_uuid, hard-coded once: register-release returns it
// (see publishRelease() below), and so does issue-license.
static final String MY_PRODUCT_UUID = "b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8";
// CI/release pipeline side - never ship the admin key in your app.
static void publishRelease(String adminApiKey) throws Exception {
JsonNode registered = ReleaseManagement.registerRelease(API_URL, adminApiKey, Map.of(
"product_id", "my-app",
"version", "2.1.0",
"channel", "stable",
"platform", "darwin-arm64",
"artifact_url", "https://cdn.example.com/my-app/2.1.0/darwin-arm64.tar.gz",
"checksum", "sha256:abc123..."
));
System.out.println("Registered; product_uuid " + registered.get("product_uuid").asText());
}
// End-user app side.
static void checkForUpdate(String licenseKey, String currentVersion) throws Exception {
JsonNode update = ReleaseManagement.checkUpdate(API_URL, licenseKey, "stable", "darwin-arm64", currentVersion);
if (update.path("update_available").asBoolean(false)) {
ObjectNode release = (ObjectNode) update.get("release");
String publicKeyPem = ReleaseManagement.fetchPublicKey(API_URL); // cache or embed it
if (ReleaseManagement.verifyRelease(release, publicKeyPem, MY_PRODUCT_UUID)) {
System.out.println("New version available: " + release.get("version").asText());
// Fetch artifact_url yourself and confirm it hashes to
// `checksum` before running/installing anything from it.
} else {
System.err.println("Update signature invalid or for the wrong product -- discarding");
}
} else {
System.out.println("Already up to date");
}
}
public static void main(String[] args) throws Exception {
checkForUpdate(System.getenv("LICENSE_KEY"), "2.0.0");
}
}Go
Complete Integration Example
package main
import (
"bytes"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/signal"
"runtime"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
)
type LicenseClient struct {
licenseKey string
apiURL string
productUUID string
instanceID string
token string
httpClient *http.Client
isActivated bool
licenseInfo map[string]interface{}
usageTracker *UsageTracker
stopChan chan struct{}
invalidLicense chan struct{}
wg sync.WaitGroup
mu sync.RWMutex
shutdownOnce sync.Once
}
// UsageTracker field names map directly to the license-server metric
// names they'll be reported under. track-usage keeps a running count
// that never resets by itself, so it meters things that accumulate
// (API calls), not a level like concurrent sessions.
type UsageTracker struct {
apiCallsPerDay int64
}
// isRefusal: true when the server answered no -- a 4xx other than 429.
// 429, 5xx and transport errors mean there was no answer, not a "no".
func isRefusal(status int) bool {
return status >= 400 && status < 500 && status != 429
}
func NewLicenseClient(licenseKey, apiURL, productUUID string) *LicenseClient {
return &LicenseClient{
licenseKey: licenseKey,
apiURL: apiURL,
productUUID: productUUID,
instanceID: generateInstanceID(),
httpClient: &http.Client{Timeout: 30 * time.Second},
usageTracker: &UsageTracker{},
stopChan: make(chan struct{}),
invalidLicense: make(chan struct{}, 1),
}
}
func generateInstanceID() string {
hostname, _ := os.Hostname()
goos := runtime.GOOS
timestamp := fmt.Sprintf("%d", time.Now().UnixNano())
combined := fmt.Sprintf("%s-%s-%s", hostname, goos, timestamp)
hash := sha256.Sum256([]byte(combined))
return fmt.Sprintf("%x", hash[:8])
}
func errorField(result map[string]interface{}) string {
if errStr, ok := result["error"].(string); ok {
return errStr
}
return "unknown error"
}
func (lc *LicenseClient) Initialize() error {
// Verify license: 200 {valid:true,...}, 404/403 {valid:false,error}
verification, status, err := lc.verifyLicense()
if err != nil {
return fmt.Errorf("license server unreachable: %w", err)
}
if isRefusal(status) {
return fmt.Errorf("invalid license: %s", errorField(verification))
}
if valid, _ := verification["valid"].(bool); status != http.StatusOK || !valid {
return fmt.Errorf("license server unavailable (HTTP %d)", status)
}
// A valid key for a different product is still the wrong license.
if productUUID, _ := verification["product_uuid"].(string); productUUID != lc.productUUID {
return errors.New("invalid license: issued for a different product")
}
// Activate license
activation, status, err := lc.activateLicense()
if err != nil {
return fmt.Errorf("license server unreachable: %w", err)
}
if isRefusal(status) {
return fmt.Errorf("activation refused: %s", errorField(activation))
}
if activated, _ := activation["activated"].(bool); status != http.StatusOK || !activated {
return fmt.Errorf("license server unavailable (HTTP %d)", status)
}
// "token" is only present on the call that creates the activation --
// an already-activated replay omits it, and without it this process
// can neither track usage nor deactivate. This example's instance id is
// per-process, so that only happens if the id was reused: free the seat
// (license portal, or POST /admin/deactivate-by-instance-id) and retry.
token, _ := activation["token"].(string)
if token == "" {
return fmt.Errorf("instance %s is already activated but this process has no token for it; free that seat and retry", lc.instanceID)
}
lc.mu.Lock()
lc.token = token
lc.isActivated = true
lc.licenseInfo = verification
lc.mu.Unlock()
// Start background tasks
lc.startBackgroundTasks()
fmt.Println("License initialized successfully")
return nil
}
func (lc *LicenseClient) verifyLicense() (map[string]interface{}, int, error) {
payload := map[string]interface{}{
"key": lc.licenseKey,
}
return lc.makeRequest("POST", "/verify-license", payload)
}
func (lc *LicenseClient) activateLicense() (map[string]interface{}, int, error) {
// Only key/instance_id are accepted -- the server has no
// instance_info field.
payload := map[string]interface{}{
"key": lc.licenseKey,
"instance_id": lc.instanceID,
}
return lc.makeRequest("POST", "/activate-license", payload)
}
func (lc *LicenseClient) validateLicense() (map[string]interface{}, int, error) {
payload := map[string]interface{}{
"key": lc.licenseKey,
"instance_id": lc.instanceID,
}
return lc.makeRequest("POST", "/validate-license", payload)
}
// trackUsage reports one metric per call -- metric must already be a key
// in this license's `limits`. Requires lc.token (the activation token
// captured in Initialize()) -- the license key alone is not accepted.
// trackUsage returns an error for anything but a 200, e.g. 403 "Usage limit
// exceeded", and retry=true when sending it again later is right: a 429 or
// 5xx, or a request that never got through. A refusal won't change, and
// after a timeout the server may have counted it already.
func (lc *LicenseClient) trackUsage(metric string, increment int64) (retry bool, err error) {
lc.mu.RLock()
token := lc.token
lc.mu.RUnlock()
payload := map[string]interface{}{
"key": lc.licenseKey,
"token": token,
"metric": metric,
"increment": increment,
}
result, status, err := lc.makeRequest("POST", "/track-usage", payload)
if err != nil {
var netErr net.Error
timedOut := errors.As(err, &netErr) && netErr.Timeout()
return !timedOut, err
}
if status != http.StatusOK {
return !isRefusal(status), fmt.Errorf("HTTP %d: %s", status, errorField(result))
}
return false, nil
}
// usageRemaining is how much of metric's limit is left, from usage-report.
func (lc *LicenseClient) usageRemaining(metric string) (int64, error) {
result, status, err := lc.makeRequest("POST", "/usage-report", map[string]interface{}{"key": lc.licenseKey})
if err != nil {
return 0, err
}
if status != http.StatusOK {
return 0, fmt.Errorf("HTTP %d: %s", status, errorField(result))
}
metrics, _ := result["metrics"].([]interface{})
for _, m := range metrics {
entry, _ := m.(map[string]interface{})
if entry["metric"] == metric {
remaining, _ := entry["remaining"].(float64)
return int64(remaining), nil
}
}
return 0, fmt.Errorf("no limit for %s", metric)
}
// makeRequest returns the decoded body and the HTTP status. err is set only
// when there was no usable answer (transport error, non-JSON body); callers
// decide what a status means.
func (lc *LicenseClient) makeRequest(method, endpoint string, payload map[string]interface{}) (map[string]interface{}, int, error) {
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, 0, err
}
url := lc.apiURL + endpoint
req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := lc.httpClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, resp.StatusCode, fmt.Errorf("HTTP %d with a non-JSON body: %w", resp.StatusCode, err)
}
return result, resp.StatusCode, nil
}
func (lc *LicenseClient) startBackgroundTasks() {
// Start validation goroutine
lc.wg.Add(1)
go lc.validationLoop()
// Start usage tracking goroutine
lc.wg.Add(1)
go lc.usageTrackingLoop()
}
func (lc *LicenseClient) validationLoop() {
defer lc.wg.Done()
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
validation, status, err := lc.validateLicense()
if err != nil || (status != http.StatusOK && !isRefusal(status)) {
// No answer (unreachable, 429, 5xx): keep running and try
// again next tick -- an outage is not a revocation.
fmt.Printf("License server unavailable (HTTP %d, %v) - will retry\n", status, err)
continue
}
// validate-license answers 200 {"valid": bool} -- no error
// field to read a reason from.
if valid, _ := validation["valid"].(bool); !valid {
fmt.Printf("License validation failed\n")
lc.handleInvalidLicense()
return
}
case <-lc.stopChan:
return
}
}
}
func (lc *LicenseClient) usageTrackingLoop() {
defer lc.wg.Done()
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
lc.flushUsage()
case <-lc.stopChan:
return
}
}
}
// flushUsage reports everything counted since the last successful report.
// SwapInt64 takes the count and zeroes it in one step, so calls recorded
// while the request is in flight aren't lost; a failed report puts its
// count back to go out with the next one.
func (lc *LicenseClient) flushUsage() {
n := atomic.SwapInt64(&lc.usageTracker.apiCallsPerDay, 0)
if n == 0 {
return
}
retry, err := lc.trackUsage("api_calls_per_day", n)
switch {
case err == nil:
case retry:
// Not counted: report it next time.
fmt.Printf("Usage tracking error (api_calls_per_day): %v\n", err)
atomic.AddInt64(&lc.usageTracker.apiCallsPerDay, n)
case strings.Contains(err.Error(), "Usage limit exceeded"):
// The server refuses the whole increment when it would pass the
// limit. Send the part that still fits, so usage reaches the limit
// and usage-report shows nothing remaining; the rest is over it.
if remaining, rerr := lc.usageRemaining("api_calls_per_day"); rerr == nil && remaining > 0 {
lc.trackUsage("api_calls_per_day", remaining)
}
fmt.Printf("Usage limit reached (%d calls in this report)\n", n)
default:
// Refused for good, or a timeout after which the server may have
// counted it already: better to undercount than to count twice.
fmt.Printf("Usage report not sent, dropping %d: %v\n", n, err)
}
}
func (lc *LicenseClient) handleInvalidLicense() {
fmt.Println("License is invalid - application should shut down")
// Signal main rather than calling Shutdown() directly: this runs
// inside validationLoop, one of the goroutines Shutdown() itself
// waits on via wg.Wait() -- calling Shutdown() from here would wait
// on this very goroutine to finish, which never happens because
// it's the one doing the waiting. The non-blocking send is safe to
// call more than once; the buffered channel only needs to fire once.
select {
case lc.invalidLicense <- struct{}{}:
default:
}
}
func (lc *LicenseClient) RecordAPICall() {
atomic.AddInt64(&lc.usageTracker.apiCallsPerDay, 1)
}
func (lc *LicenseClient) Shutdown() {
// sync.Once guards against a double-close panic on lc.stopChan if
// main and an invalid-license notification both end up calling
// Shutdown() around the same time -- only the first call's body
// actually runs.
lc.shutdownOnce.Do(func() {
fmt.Println("Shutting down license client...")
// Stop the loops first, then send the last usage while the
// activation token is still good, then give the seat back.
close(lc.stopChan)
lc.wg.Wait()
lc.mu.RLock()
isActivated := lc.isActivated
lc.mu.RUnlock()
if isActivated {
lc.flushUsage()
if err := lc.deactivateLicense(); err != nil {
fmt.Printf("License deactivation failed - seat still held: %v\n", err)
}
}
})
}
func (lc *LicenseClient) deactivateLicense() error {
lc.mu.RLock()
token := lc.token
lc.mu.RUnlock()
payload := map[string]interface{}{
"key": lc.licenseKey,
"token": token,
}
result, status, err := lc.makeRequest("POST", "/deactivate-license", payload)
if err != nil {
return err
}
if status != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", status, errorField(result))
}
if deactivated, _ := result["deactivated"].(bool); !deactivated {
// No activation matched the token: the seat was already freed.
fmt.Println("No activation to deactivate (already freed)")
return nil
}
fmt.Println("License deactivated successfully")
return nil
}
// Getters
func (lc *LicenseClient) IsActivated() bool {
lc.mu.RLock()
defer lc.mu.RUnlock()
return lc.isActivated
}
func (lc *LicenseClient) GetLicenseInfo() map[string]interface{} {
lc.mu.RLock()
defer lc.mu.RUnlock()
return lc.licenseInfo
}
func (lc *LicenseClient) GetInstanceID() string {
return lc.instanceID
}
// Your product's product_uuid (from issue-license or register-release),
// hard-coded once.
const productUUID = "b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8"
// Main usage example
func main() {
licenseKey := os.Getenv("LICENSE_KEY")
if licenseKey == "" {
fmt.Println("LICENSE_KEY environment variable not set")
os.Exit(1)
}
licenseClient := NewLicenseClient(licenseKey, "https://your-license-api.com/v1", productUUID)
// Refused or unreachable, this process can't take a seat, so it
// doesn't start. The error says which.
if err := licenseClient.Initialize(); err != nil {
fmt.Printf("Failed to initialize license: %v\n", err)
os.Exit(1)
}
fmt.Println("Application starting with valid license")
// Simulate application work
go func() {
for {
licenseClient.RecordAPICall()
time.Sleep(1 * time.Second)
}
}()
// Wait for a termination signal or an invalid-license notification -
// SIGTERM is what a container orchestrator sends (docker stop, a
// Kubernetes pod termination); os.Interrupt (SIGINT) is what a local
// Ctrl-C sends. Both need to deactivate: there's no automatic
// expiration on an activation, so a process that exits without
// catching either signal leaves its seat permanently occupied until
// it's manually freed via POST /admin/deactivate-by-instance-id.
// invalidLicense fires when validationLoop's periodic check fails;
// it can't call Shutdown() itself (see handleInvalidLicense's own
// comment), so main does it here instead.
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
select {
case <-c:
// Stop intercepting the signal so a second Ctrl-C/SIGTERM
// terminates the process immediately instead of being silently
// swallowed while Shutdown() (each request bounded by
// httpClient's 30-second timeout) is still running.
signal.Stop(c)
fmt.Println("\nShutdown requested...")
case <-licenseClient.invalidLicense:
fmt.Println("\nShutting down due to invalid license...")
}
licenseClient.Shutdown()
}For general integration best practices (error handling, performance, security, monitoring), see the Integration Checklist.
Software Distribution: registering releases and checking for updates
Standalone functions, not methods on LicenseClient above. RegisterRelease is admin/tenant-key gated — call it from your own CI or release pipeline, never from an end-user app; see Registering a Release for the curl/Node equivalent and every error code. CheckUpdate/VerifyRelease are what an end-user app calls, mirroring the raw JS example in Auto-Update Checking — there's no published Go package for this SDK, only this reference implementation using the standard library alone (net/http, encoding/json, crypto/rsa).
encoding/json on a map reorders fields — a struct doesn't
The signature check below only works because verifyRelease reconstructs the manifest as a struct with fields declared in the exact order register-release.js's buildReleaseManifest() signed (product_id, version, channel, platform, artifact_url, checksum, release_notes, issued_at, product_uuid), and Go's encoding/json marshals a struct in its declared field order. Decode the same JSON into a map[string]interface{} instead and re-marshal it, and Marshal will alphabetize the keys — a different byte string, a signature check that fails 100% of the time, and no error to explain why. This was confirmed directly against a running License Server, not assumed: the first draft of this example decoded into a map, verified nothing, and left no clue that key order was the culprit.
A second default bites the same way: encoding/json HTML-escapes <, > and & in strings unless told not to — JSON.stringify() never does. An artifact_url with a & in a query string (a realistic case) silently breaks verification unless the encoder is built with SetEscapeHTML(false), confirmed the same way by re-running this exact example against a release whose artifact_url contained one.
A third escape SetEscapeHTML(false) doesn't turn off: encoding/json always writes U+2028 and U+2029 (line and paragraph separator) as \u2028/\u2029, while JSON.stringify() writes them raw. Release notes pasted from a word processor can contain one, and verification then fails for an untampered release, so verifyRelease puts them back with unescapeLineSeparators() before hashing (it skips an escaped backslash followed by the text u2028, which is literal text, not the character).
package main
import (
"bytes"
"crypto"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"time"
)
var httpClient = &http.Client{Timeout: 30 * time.Second}
func registerRelease(apiURL, adminAPIKey string, release map[string]interface{}) (map[string]interface{}, error) {
body, err := json.Marshal(release)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", apiURL+"/register-release", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+adminAPIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
errMsg := ""
if e, ok := result["error"].(string); ok {
errMsg = ": " + e
}
return nil, fmt.Errorf("register-release failed with %d%s", resp.StatusCode, errMsg)
}
return result, nil // {id, status, product_uuid}
}
type checkUpdateResponse struct {
UpdateAvailable bool `json:"update_available"`
Release *releaseWithSig `json:"release"`
}
// releaseManifest's field order matters -- it must match, field for
// field, the order register-release.js's buildReleaseManifest() signed.
// Go marshals a *struct* in field-declaration order (unlike a map, which
// it always sorts alphabetically), so this struct doubles as the exact
// recipe for reproducing the signed bytes.
type releaseManifest struct {
ProductID string `json:"product_id"`
Version string `json:"version"`
Channel string `json:"channel"`
Platform string `json:"platform"`
ArtifactURL string `json:"artifact_url"`
Checksum string `json:"checksum"`
ReleaseNotes *string `json:"release_notes"`
IssuedAt string `json:"issued_at"`
ProductUUID string `json:"product_uuid"`
}
type releaseWithSig struct {
releaseManifest
Signature string `json:"signature"`
}
func checkUpdate(apiURL, licenseKey, channel, platform, currentVersion string) (*checkUpdateResponse, error) {
body, _ := json.Marshal(map[string]interface{}{
"key": licenseKey,
"channel": channel,
"platform": platform,
"current_version": currentVersion,
})
resp, err := httpClient.Post(apiURL+"/check-update", "application/json", bytes.NewReader(body))
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("check-update failed with %d", resp.StatusCode)
}
var result checkUpdateResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, err
}
return &result, nil
}
// verifyRelease reproduces the same RSA/SHA256-over-JSON.stringify()
// scheme as offline license verification -- see the warning above for
// why this marshals a struct, and why SetEscapeHTML(false) is required.
func verifyRelease(release *releaseWithSig, publicKeyPEM, expectedProductUUID string) (bool, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(release.releaseManifest); err != nil {
return false, err
}
data := bytes.TrimRight(buf.Bytes(), "\n") // Encode appends a trailing newline
data = unescapeLineSeparators(data)
block, _ := pem.Decode([]byte(publicKeyPEM))
if block == nil {
return false, errors.New("failed to decode PEM public key")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return false, err
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
return false, errors.New("not an RSA public key")
}
sigBytes, err := base64.StdEncoding.DecodeString(release.Signature)
if err != nil {
return false, err
}
hashed := sha256.Sum256(data)
if err := rsa.VerifyPKCS1v15(rsaPub, crypto.SHA256, hashed[:], sigBytes); err != nil {
return false, nil // doesn't verify -- not a hard error
}
return release.ProductUUID == expectedProductUUID, nil
}
// unescapeLineSeparators undoes the one escape SetEscapeHTML(false) doesn't
// turn off: encoding/json always writes U+2028/U+2029 as \u2028/\u2029,
// JSON.stringify() writes them raw. It walks escapes two bytes at a time,
// so an escaped backslash followed by "u2028" (`\\u2028`, literal text)
// is left alone.
func unescapeLineSeparators(data []byte) []byte {
out := make([]byte, 0, len(data))
for i := 0; i < len(data); i++ {
if data[i] != '\\' || i+1 >= len(data) {
out = append(out, data[i])
continue
}
if rest := data[i+1:]; bytes.HasPrefix(rest, []byte("u2028")) || bytes.HasPrefix(rest, []byte("u2029")) {
r := '\u2028'
if rest[4] == '9' {
r = '\u2029'
}
out = append(out, string(r)...)
i += 5
continue
}
out = append(out, data[i], data[i+1]) // any other escape, kept as is
i++
}
return out
}// Usage
registered, err := registerRelease(apiURL, adminAPIKey, map[string]interface{}{
"product_id": "my-app",
"version": "2.1.0",
"channel": "stable",
"platform": "darwin-arm64",
"artifact_url": "https://cdn.example.com/my-app/2.1.0/darwin-arm64.tar.gz",
"checksum": "sha256:abc123...",
})
if err != nil {
log.Fatal(err)
}
productUUID := registered["product_uuid"].(string) // fetch once, hard-code it
update, err := checkUpdate(apiURL, licenseKey, "stable", "darwin-arm64", currentVersion)
if err != nil {
log.Fatal(err)
}
if update.UpdateAvailable {
resp, err := httpClient.Get(apiURL + "/public-key")
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
pubKeyBody, _ := io.ReadAll(resp.Body)
var pubKeyResp struct {
PublicKey string `json:"publicKey"`
}
json.Unmarshal(pubKeyBody, &pubKeyResp)
valid, err := verifyRelease(update.Release, pubKeyResp.PublicKey, productUUID)
if err != nil {
log.Fatal(err)
}
if valid {
fmt.Println("New version available:", update.Release.Version)
} else {
fmt.Println("Manifest signature verification failed -- discarding")
}
} else {
fmt.Println("Already up to date")
}Registering a Release
Unlike check-update below (public, license-key-gated — your end users' apps call it), this is the publisher side of Software Distribution: an admin/tenant-key-gated call your own CI or release pipeline makes, not something an end-user app ever calls. See the API reference for every field limit and error code.
Request:
curl -X POST https://your-license-api.com/v1/register-release \
-H "Authorization: Bearer your-tenant-api-key" \
-H "Content-Type: application/json" \
-d '{
"product_id": "widget-pro",
"version": "2.3.0",
"channel": "stable",
"platform": "darwin-arm64",
"artifact_url": "https://cdn.example.com/widget-pro/2.3.0/widget-pro-mac.dmg",
"checksum": "sha256:...",
"release_notes": "Fixes a rare crash on startup."
}'Response:
{
"id": 42,
"status": "published",
"product_uuid": "b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8"
}Capture product_uuid once, here or from issue-license — it never changes for a given product_id under your own tenant, and it's what checkUpdate()'s expectedProductUuid argument needs (see below).
Node.js: registering a release from a CI pipeline
There's no @casazium/license-sdk method for this — checkUpdate() covers the consuming side only; registering, listing, and unpublishing releases have no SDK wrapper at all. A plain authenticated fetch call, the same shape the SDK's own Node/CLI-only admin methods (listLicenses/revoke) use under the hood:
async function registerRelease(apiUrl, adminApiKey, release) {
const res = await fetch(`${apiUrl}/register-release`, {
method: 'POST',
headers: {
Authorization: `Bearer ${adminApiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(release),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(`register-release failed with ${res.status}${body.error ? `: ${body.error}` : ''}`);
}
return res.json(); // { id, status, product_uuid }
}
// Usage - e.g. from your release pipeline, right after the build/upload step
const { id, product_uuid } = await registerRelease(
'https://your-license-api.com/v1',
process.env.LICENSE_ADMIN_API_KEY,
{
product_id: 'widget-pro',
version: '2.3.0',
channel: 'stable',
platform: 'darwin-arm64',
artifact_url: 'https://cdn.example.com/widget-pro/2.3.0/widget-pro-mac.dmg',
checksum: 'sha256:...',
release_notes: 'Fixes a rare crash on startup.',
}
);
console.log(`Registered release ${id} for product ${product_uuid}`);Common failure modes worth handling explicitly rather than as a generic error (full list in the API reference):
409— a published release already exists for this exactproduct_id/version/channel/platformcombination. Unpublish the old one first, or bump the version.403 Release limit reached— that channel/platform bucket is at its 500-release cap (see The 500-release limit); unpublish old releases to free a slot.403 Subscription is not active— hosted tier only, your payment has failed.
Auto-Update Checking
This documents the raw POST /v1/check-update contract only — see Software Distribution for the full picture. Framework-specific integration guides (Tauri, electron-updater, Squirrel) are planned as a fast-follow and not covered here yet.
Request:
curl -X POST https://your-license-api.com/v1/check-update \
-H "Content-Type: application/json" \
-d '{
"key": "your-customers-license-key",
"channel": "stable",
"platform": "darwin-arm64",
"current_version": "2.2.9"
}'The releases this can return are scoped to the license's own product_uuid, resolved server-side from the license itself — product_id in the request body is optional now (a defense-in-depth convenience only; see Product IDs are per-tenant, not global).
Response (update_available: false, release: null when nothing published matches, or when current_version is already current):
{
"update_available": true,
"release": {
"product_id": "widget-pro",
"version": "2.3.0",
"channel": "stable",
"platform": "darwin-arm64",
"artifact_url": "https://cdn.example.com/widget-pro/2.3.0/widget-pro-mac.dmg",
"checksum": "sha256:...",
"release_notes": "Fixes a rare crash on startup.",
"signature": "base64-RSA-SHA256-signature...",
"issued_at": "2026-08-19T00:00:00.000Z",
"product_uuid": "b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8"
}
}Field order matters when verifying signature: the server signs the plain JSON.stringify() of the manifest with no canonical/sorted-key serialization, so your client must reconstruct the object in the exact order shown above (with signature itself excluded, and product_uuid omitted entirely — not null — on the rare release that has none) before stringifying it — see the API reference for the full explanation. This mainly bites non-JavaScript clients: a JSON encoder that sorts keys (Python's json.dumps(sort_keys=True), Go's encoding/json on a map) or escapes characters JSON.stringify() leaves raw (Go's <, >, &, U+2028 and U+2029) will produce a mismatch even for an untampered response.
Node.js: checking for an update and verifying the signature
Same offline-verification scheme as an exported license file (see the Python verify_signed_license example above) — signature is an RSA/SHA256 signature over the release object with signature itself removed, verified against the public key from GET /v1/public-key.
If you're on @casazium/license-sdk v0.2.0+, its checkUpdate() does all of this for you (signature verification aside) and requires an expectedProductUuid argument - see Software Distribution. The raw version below shows what it does under the hood, useful if you're not on Node/the SDK:
import crypto from 'node:crypto';
async function checkForUpdate(apiUrl, licenseKey, currentVersion) {
const res = await fetch(`${apiUrl}/check-update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: licenseKey,
channel: 'stable',
platform: `${process.platform}-${process.arch}`,
current_version: currentVersion,
}),
});
if (!res.ok) {
throw new Error(`check-update failed: ${res.status}`);
}
return res.json();
}
async function verifyRelease(apiUrl, release, expectedProductUuid) {
const { signature, ...manifest } = release;
// Fetching the public key here, on every check, only proves the
// manifest matches whatever key the server hands back right now - it
// adds nothing beyond what TLS already gives you against a
// compromised or malicious server. The real offline-verification
// value of this signature only shows up if you cache/pin the public
// key out of band (embed it in your build, or fetch and store it once)
// instead of re-fetching it alongside every manifest.
const keyRes = await fetch(`${apiUrl}/public-key`);
const { publicKey } = await keyRes.json();
const verifier = crypto.createVerify('SHA256');
verifier.update(JSON.stringify(manifest));
verifier.end();
const authentic = verifier.verify(publicKey, signature, 'base64');
// Authenticity alone doesn't prove product identity (see "Product IDs
// are per-tenant, not global" in the API reference) - the server
// already scopes candidate releases to your license's own
// product_uuid, but this is cheap client-side defense-in-depth on top
// of that, the same check @casazium/license-sdk's checkUpdate() makes
// for you.
return authentic && manifest.product_uuid === expectedProductUuid;
}
// Usage
const MY_PRODUCT_UUID = 'b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8'; // your own product_uuid, hard-coded once
const { update_available, release } = await checkForUpdate(
'https://your-license-api.com/v1',
process.env.LICENSE_KEY,
APP_VERSION
);
if (update_available) {
const authentic = await verifyRelease('https://your-license-api.com/v1', release, MY_PRODUCT_UUID);
if (!authentic) {
throw new Error('Update signature invalid or for the wrong product -- refusing to trust this release');
}
// A valid signature only proves the manifest wasn't tampered with, not
// that it's current -- unpublishing a release doesn't retroactively
// invalidate a signature you already have. If you cache this response
// instead of acting on it immediately, record when *you* fetched it and
// re-check once that's too old; issued_at is when the release was
// registered, not when you fetched it, so it can't tell you that.
console.log(`Update available: ${release.version}`, release.artifact_url);
// Fetch release.artifact_url yourself and confirm it hashes to
// release.checksum before running/installing anything from it.
}Next Steps
- Error Handling - Comprehensive error management strategies
- API Reference - Complete endpoint documentation
- Getting Started - Set up your License Server