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.

Base URL
https://careful-cyan-beetle.78-46-36-153.cpanel.site/api/v2
Transport
HTTPS · JSON
Request auth
Ed25519 device signature
Tokens
PASETO v4.public
Timestamp window
±5 minutes
Replay defense
nonce + monotonic counter
Why this is secure: the device private key never leaves the machine, so a copied activation is useless elsewhere. Certificates, leases, update manifests and the revocation list are all signed by the authority and verifiable offline — a stolen file or a fake server cannot forge them.

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.

license kid: lic-2026-clzy
yobRAtQvZREDIPVwIiIlL8zlzce4m2kR3OXjrWZZ9+Q=
lease kid: lease-2026-et4e
6mSBwvXEBkFG8Pt5ya0NOStoId8tl6e7FQvkJzNkIdY=
release kid: rel-2026-vik7
nR9BCPxp7mPa5bluKZmH6HVCn0PMv7Tf1LfZZp/hjq4=

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.

PHP — generate & persist the device key (once)
$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).

Canonical signing input (exact byte layout)
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 )
PHP — build & sign
$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));
The server enforces the timestamp window (±300s), single-use nonces, and a strictly increasing counter per device. Keep the device clock roughly in sync.

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.

The entire client API
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.

Prefer to build your own client? The signing + verification contract is fully specified in the Signing Requests and Verifying Tokens Offline sections. The platform accepts the documented body scheme; send an optional X-LP-Scheme: v2-body header to name it explicitly.
POST/api/v2/license/activate
Enrolls this device and activates the license. Idempotent for the same device on the same binding. Returns a signed activation certificate, the license certificate, the current CRL, and the first lease.
FieldTypeRequiredDescription
license_keystring required The license key
device_public_keystring required Base64 Ed25519 device public key
fingerprintobject recommended { domain, server, hardware } — used for binding
timestampinteger required Unix seconds (±300s window)
noncestring required Random hex, single-use
counterinteger required Monotonic, +1 per request
device_sigstring 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
POST/api/v2/license/heartbeat
Renews the lease and returns a fresh CRL so your revocation list never goes stale (no need to poll /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.
Request
{
  "license_key": "...",
  "activation_id": "uuid",
  "fingerprint": { ... },
  "timestamp": 1750000000,
  "nonce": "...",
  "counter": 7,
  "device_sig": "..."
}
Success / Revocation
{ "success": true, "status": "active",
  "lease": "v4.public...",
  "crl": "v4.public...",
  "expires_at": "...", "grace_until": "..." }

// If revoked:
{ "success": false, "status": "revoked",
  "revocation": "v4.public..." }
POST/api/v2/license/validate
Checks status and returns a fresh lease + CRL. Same request and response shape as heartbeat. Use it for an on-demand re-check.
POST/api/v2/license/deactivate
Releases this device's activation and frees the slot. Call it from your uninstaller. Same request shape as heartbeat.
POST/api/v2/license/transfer
Migrates an activation to a new device/binding (controlled, capped by policy). Signed by the current device key; also include new_device_public_key and new_device_sig (the new device co-signs the same canonical input).
GET/api/v2/release/check
Lightweight "is there an update?" poll for auto-updaters. Query: ?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 (200)
{
  "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"
}
GET/api/v2/release/manifest
Returns the signed release manifest for the license's product. Query: ?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 (200)
{
  "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=..."
}
GET/api/v2/crl
The signed Certificate Revocation List. Fetch periodically, verify the 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 (200)
{ "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.

StepWhat 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://mail.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.
Request code = base64( the normal /activate request body )
$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, ...}
The 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.

PHP — verify a v4.public token
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.
The bundled docs/sdk/LicenseClient.php implements this (including the PAE helper) — copy it rather than re-implementing.

Error Codes

CodeHTTPMeaning & 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.

Every error response has this exact shape
{
  "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
}
RuleWhy 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.
Domain lock: a license is permanently bound to the first domain it activates on (the registrable domain — apex and all its subdomains). Activating or transferring to a different domain returns DOMAIN_LOCKED (HTTP 409). Surface message so the user is told to reuse the original domain or purchase a new license.
Recommended server-side relay — never blanks, always shows the reason
// 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()]);
}
On the front end, read 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

ActionLimit
activate / deactivate10 / min / IP
validate60 / min / IP
heartbeat120 / min / IP
other (transfer, release, crl)30 / min / IP

Exceeding a limit returns HTTP 429 with Retry-After.

WHBOS License Platform · License API v2 · Ed25519 / PASETO v4.public Admin Panel