License API v2
A device-key licensing authority. Each installation generates its own Ed25519 key, signs every request (proof-of-possession), and verifies signed certificates and leases offline against pinned public keys.
https://careful-cyan-beetle.78-46-36-153.cpanel.site/api/v2
● Ed25519 · PASETO v4.public
JSON
Overview
Integrating a license is three steps: (1) on first run your software
generates an Ed25519 device keypair and calls /activate; (2)
it stores the returned activation_id and verifies the signed certificate and
lease offline; (3) it calls /heartbeat periodically to renew
the lease and gates features on the lease state. No shared secret is ever transmitted.
Pinned Public Keys
Embed these Ed25519 public keys in your software to verify signed tokens offline. The
license key verifies certificates and the CRL, lease verifies
leases and revocation notices, and release verifies update manifests.
They are also shown in the admin panel under System Health.
Device Identity
On first launch, generate an Ed25519 keypair and persist it securely on the device
(file with 0600 perms, OS keystore, etc.). The private key signs every request;
the public key is sent once during activation to enroll the device.
$pair = sodium_crypto_sign_keypair(); file_put_contents($keyPath, base64_encode($pair)); // chmod 0600 $secret = sodium_crypto_sign_secretkey($pair); // signs requests — keep on device $public = sodium_crypto_sign_publickey($pair); // sent on /activate $devicePublicKeyB64 = base64_encode($public);
Signing Requests
Every request includes timestamp, nonce, counter and a
device_sig. The signature is an Ed25519 signature over a deterministic
canonical input — a newline-joined field list (not JSON), so it reproduces
identically in any language. Increment counter on every request (replay defense).
v2.lic-req
{HTTP_METHOD} e.g. POST
{path} e.g. api/v2/license/activate (no leading slash)
{timestamp} unix seconds
{nonce} random hex (16 bytes)
{counter} monotonic integer, +1 per request
{license_key}
{activation_id} empty string on /activate
{device_public_key} base64
{fingerprint_hash} sha256( domain \x1f server \x1f hardware )
$fpHash = hash('sha256', implode("\x1f", [$fp['domain'] ?? '', $fp['server'] ?? '', $fp['hardware'] ?? '']));
$message = implode("\n", [
'v2.lic-req', 'POST', 'api/v2/license/activate',
$timestamp, $nonce, $counter, $licenseKey, $activationId ?? '', $devicePublicKeyB64, $fpHash,
]);
$deviceSig = base64_encode(sodium_crypto_sign_detached($message, $deviceSecret));
PHP SDK — Drop-in Client
The recommended way to integrate. The official KodeHarbor License SDK handles
everything below — device key management, request signing (the v2-body scheme), and
the full offline lease / certificate / CRL validation. The pinned public keys come pre-filled, so
a new product is a two-step integration: set product_slug, then call the guard
wherever you enforce.
📥 Download SDK (.zip) Always the version bundled with this platform release.
use KodeHarbor\License\LicenseGuard;
// 1) Once, in your setup wizard:
LicenseGuard::activate($licenseKey); // mints device key, enrolls, stores + verifies tokens
// 2) Anywhere you enforce — the ONLY call sites you ever need:
LicenseGuard::ok(); // bool — active or grace?
LicenseGuard::has('reports.advanced'); // bool — entitlement present?
// 3) Keep the lease + CRL fresh (schedule daily):
LicenseGuard::heartbeat();
// 4) Uninstaller — release the seat:
LicenseGuard::deactivate();
Every call reads the same cryptographically-verified state, so you can scatter
LicenseGuard::ok() across middleware, gates, boot preflight and module loaders for
defense-in-depth — removing any one check leaves the crypto and the others intact. See the SDK's
README.md for the single-check and defense-in-depth patterns.
X-LP-Scheme: v2-body header to name it explicitly.
| Field | Type | Required | Description |
|---|---|---|---|
license_key | string | required | The license key |
device_public_key | string | required | Base64 Ed25519 device public key |
fingerprint | object | recommended | { domain, server, hardware } — used for binding |
timestamp | integer | required | Unix seconds (±300s window) |
nonce | string | required | Random hex, single-use |
counter | integer | required | Monotonic, +1 per request |
device_sig | string | required | Ed25519 signature of the canonical input |
Success (200)
{
"success": true,
"status": "activated",
"activation_id": "uuid",
"activation_certificate": "v4.public...",
"certificate": "v4.public...",
"license_certificate": "v4.public...", // alias of `certificate`
"crl": "v4.public...", // seed your revocation list
"lease": "v4.public...",
"expires_at": "2026-06-14T...",
"grace_until": "2026-06-21T...",
"max_activations": 1,
"activations_used": 1
}Errors
{ "success": false,
"error": "MAX_ACTIVATIONS",
"message": "..." }
// INVALID_LICENSE LICENSE_EXPIRED
// INVALID_SIGNATURE MISSING_DEVICE_KEY
// INVALID_BINDING BINDING_IN_USE
// MAX_ACTIVATIONS/api/v2/crl separately). Send on each app start and on the
cadence in the certificate policy (default every 24h). If the license was
revoked/suspended/expired, returns a signed revocation notice instead of a lease.
{
"license_key": "...",
"activation_id": "uuid",
"fingerprint": { ... },
"timestamp": 1750000000,
"nonce": "...",
"counter": 7,
"device_sig": "..."
}{ "success": true, "status": "active",
"lease": "v4.public...",
"crl": "v4.public...",
"expires_at": "...", "grace_until": "..." }
// If revoked:
{ "success": false, "status": "revoked",
"revocation": "v4.public..." }new_device_public_key and
new_device_sig (the new device co-signs the same canonical input).
?key=LICENSE_KEY&channel=stable¤t=1.2.0. Compares the client's
current version against the latest release on the channel (semantic comparison)
and returns whether an update exists, plus the changelog and the manifest URL to fetch and
verify before installing. Only requires a valid license (no active activation), so it's
cheap to call on a schedule.
{
"success": true,
"update_available": true,
"current_version": "1.2.0",
"latest_version": "1.3.0",
"channel": "stable",
"changelog": "Fixes and improvements…",
"released_at": "2026-06-16T10:00:00+00:00",
"size": 5242880,
"sha256": "…",
"manifest_url": "https://careful-cyan-beetle.78-46-36-153.cpanel.site/api/v2/release/manifest?key=...&channel=stable"
}?key=LICENSE_KEY&channel=stable (optional &version=). Verify the
manifest with the pinned release key, download via download_url, then
confirm the file's SHA-256 matches the manifest before installing.
{
"success": true,
"version": "1.2.0",
"channel": "stable",
"size": 5242880,
"sha256": "…",
"manifest": "v4.public...", // verify with the release key
"download_url": "https://careful-cyan-beetle.78-46-36-153.cpanel.site/api/v2/release/download?key=..."
}crl token
with the pinned license key, and treat your certificate's cid as
revoked if it appears in revoked[]. Fail closed after next_update.
{ "success": true, "serial": 3, "count": 2,
"next_update": "2026-06-14T...",
"crl": "v4.public..." }Offline / Air-gapped Activation
For machines with no outbound internet. Instead of POSTing to /activate, the
client exchanges two copy/paste codes through the customer portal. The signing, verification
and entitlement are identical to the online flow — only the transport differs.
| Step | What happens |
|---|---|
| 1. Build request | Build the exact same JSON body you would POST to
/api/v2/license/activate (license_key, device_public_key, fingerprint,
timestamp, nonce, counter, device_sig). Sign it with method POST and
path api/v2/license/activate, then base64-encode the JSON — that is the
request code. |
| 2. Exchange | The customer signs in and pastes the request code at
https://careful-cyan-beetle.78-46-36-153.cpanel.site/offline-activation. The platform runs it through the same
Activation Authority and returns a base64 response code. |
| 3. Apply | The client base64-decodes the response code to the normal activate JSON (activation_id, activation_certificate, certificate, lease, …), verifies the tokens offline against the pinned keys, and activates. |
$body = [ 'license_key' => $key, 'device_public_key' => $pub, 'fingerprint' => $fp,
'timestamp' => time(), 'nonce' => bin2hex(random_bytes(16)), 'counter' => 1,
'device_sig' => $sigOverCanonicalInput ]; // sign with POST + api/v2/license/activate
$requestCode = base64_encode(json_encode($body)); // paste into the portal
// portal returns $responseCode:
$result = json_decode(base64_decode($responseCode), true); // {success, activation_id, lease, ...}
nonce is single-use, so each request code works once. If activation is
interrupted, generate a fresh request code rather than re-submitting the old one.
Verifying Tokens Offline
Certificates, leases, manifests and the CRL are PASETO v4.public tokens. Verify
each with the matching pinned public key — no network needed. The token's footer carries the
kid; the payload is JSON claims.
function verifyToken(string $token, string $pubKeyB64): ?array {
$pub = base64_decode($pubKeyB64);
$p = explode('.', $token);
if (count($p) < 3 || $p[0] !== 'v4' || $p[1] !== 'public') return null;
$body = sodium_base642bin(strtr($p[2],'-_','+/'), SODIUM_BASE64_VARIANT_ORIGINAL_NO_PADDING);
$payload = substr($body, 0, -SODIUM_CRYPTO_SIGN_BYTES);
$sig = substr($body, -SODIUM_CRYPTO_SIGN_BYTES);
$footer = isset($p[3]) ? sodium_base642bin(strtr($p[3],'-_','+/'), 5) : '';
// PAE("v4.public.", payload, footer, "")
$pae = pae(['v4.public.', $payload, $footer, '']);
return sodium_crypto_sign_verify_detached($sig, $pae, $pub) ? json_decode($payload, true) : null;
}
// A lease is valid while now < exp; degraded while now < grace_until; otherwise stop.
docs/sdk/LicenseClient.php implements this (including the PAE helper) — copy it rather than re-implementing.Error Codes
| Code | HTTP | Meaning & action |
|---|---|---|
| INVALID_LICENSE | 422 | Key unknown, revoked, suspended or cancelled. |
| LICENSE_EXPIRED | 422 | The license has expired. |
| MISSING_DEVICE_KEY | 422 | device_public_key is required on activate. |
| INVALID_SIGNATURE | 401 | Device signature failed, or replay/clock skew. Check counter, nonce, timestamp and the canonical input. |
| STALE_COUNTER | 401 | Counter not greater than the last seen value — increment it. |
| INVALID_BINDING | 422 | Fingerprint is missing the field the binding requires (e.g. domain). |
| BINDING_IN_USE | 409 | That binding is already active on another device. Deactivate it or use /transfer. |
| DOMAIN_LOCKED | 409 | The license is permanently bound to its first activated domain. It cannot be activated or transferred to a different registrable domain. |
| MAX_ACTIVATIONS | 409 | All activation slots are used. |
| MIGRATION_LIMIT | 409 | The activation reached its transfer limit. |
| NOT_FOUND | 404 | Activation not found. |
| PENDING_CONFIRMATION | 422 | Activation awaits confirmation in the customer portal. |
| RATE_LIMITED | 429 | Too many requests — slow down. |
Client Error Handling
Read this before you ship. Most integration bugs are not in the API — they are in how the client handles the API's response. Every endpoint returns a JSON body with a stable shape on both success and failure; build your client around that contract.
{
"success": false,
"error": "DOMAIN_LOCKED", // stable machine code — switch on this
"message": "This license has already been activated on yourdomain.com. ...",
"registered_domain": "yourdomain.com" // present on DOMAIN_LOCKED
}
| Rule | Why it matters |
|---|---|
| Always parse the body on every status | A non-2xx response (401, 409, 422, 429, 500) still carries a JSON
message. Do not treat "not 200" as "no body" — read and show the
message. |
Switch on error, display message |
The error code is stable for logic; the
message is human-readable and safe to show the end user verbatim
(e.g. the DOMAIN_LOCKED "purchase a new license" text). |
| Never emit an empty body from your own proxy | If your installer calls this API server-side and relays the
result to a browser, relay the JSON for all status codes. Swallowing
non-200 responses produces an empty body and a confusing
Unexpected end of JSON input error in the UI — the real reason is lost. |
Require the sodium extension |
Requests are Ed25519-signed with libsodium. If
ext-sodium is missing, signing fatals before any request is sent. Check for it
up front and show a clear message instead of crashing. |
| Guard your JSON encoder | When re-encoding upstream data, use
JSON_INVALID_UTF8_SUBSTITUTE so json_encode() can never silently
return false and emit a blank response. |
message so the user is told to reuse the original domain or purchase a new license.
// Emit valid JSON on every path — never a blank page.
function emitJson(array $data): void {
$json = json_encode($data, JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_SLASHES);
echo $json !== false
? $json
: json_encode(['success' => false, 'message' => 'Encoding error: '.json_last_error_msg()]);
}
header('Content-Type: application/json');
// 1) Signing needs libsodium — fail loudly, not blankly.
if (!function_exists('sodium_crypto_sign_keypair')) {
http_response_code(500);
emitJson(['success' => false, 'error' => 'NO_LIBSODIUM',
'message' => 'Enable the PHP "sodium" extension to activate a license.']);
exit;
}
try {
// $status = HTTP code, $body = raw response from the License Platform.
[$status, $body] = callLicenseApi('/api/v2/license/activate', $payload);
$data = json_decode($body, true);
http_response_code($status ?: 502);
emitJson(is_array($data) ? $data
: ['success' => false,
'message' => $body !== '' ? $body : 'No response from the license server.']);
} catch (\Throwable $e) {
http_response_code(500);
emitJson(['success' => false, 'error' => 'CLIENT_ERROR', 'message' => $e->getMessage()]);
}
data.message on failure (not just on success). With the relay
above, a wrong-domain activation shows [DOMAIN_LOCKED] This license has already been
activated on yourdomain.com… instead of a blank "network error".
Rate Limiting
| Action | Limit |
|---|---|
| activate / deactivate | 10 / min / IP |
| validate | 60 / min / IP |
| heartbeat | 120 / min / IP |
| other (transfer, release, crl) | 30 / min / IP |
Exceeding a limit returns HTTP 429 with Retry-After.