License API

v1 JSON over HTTPS Bearer credentials or HMAC signatures

All four endpoints require authentication. See Authentication.

Installation ID

Every running copy generates one identifier on first use and stores it:

inst_8f72d91c4e6b0a35c7d2e19f4b8a06cd

It must be stable across restarts and unique per installation. The SDK generates and persists it for you. Do not derive it from the hostname — moving a database to a new server should not look like a new installation.

POST /license/activate

Registers a running copy against a license. Safe to call repeatedly: if the same installation is already active the call succeeds with already_activated: true.

{
  "license_key": "XXXX-XXXX-XXXX-XXXX-XXXX",
  "product_code": "MYCRM",
  "domain": "example.com",
  "installation_id": "inst_8f72d91c4e6b0a35c7d2e19f4b8a06cd",
  "version": "2.4.1",
  "php_version": "8.3.12",
  "laravel_version": "11.0.0",
  "server_info": { "os": "Linux", "server": "nginx", "database": "MySQL 8" }
}

The server normalises the domain, so https://www.example.com/install?step=2 and example.com are the same host. server_info is optional and only the three documented keys are accepted.

Success

{
  "success": true,
  "status": "active",
  "message": "License activated.",
  "already_activated": false,
  "activation": {
    "installation_id": "inst_8f72d91c4e6b0a35c7d2e19f4b8a06cd",
    "domain": "example.com",
    "status": "active",
    "activated_at": "2026-10-03T12:00:00+00:00"
  },
  "license": {
    "id": "01J8Z...",
    "product": "MYCRM",
    "type": "subscription",
    "status": "active",
    "expires_at": "2027-12-31",
    "max_domains": 1,
    "max_activations": 1,
    "current_activations": 1
  },
  "certificate": "<compact Ed25519 token>",
  "offline_until": "2026-10-06T12:00:00+00:00",
  "meta": { "request_id": "req_9f2c1a7b4d6e" }
}

Refusal

A refusal is still a successful API call, so it answers HTTP 200 with success: false:

{
  "success": false,
  "status": "active",
  "message": "This license allows 1 activation and all 1 are in use.",
  "error_code": "ACTIVATION_LIMIT_REACHED",
  "details": { "max_activations": 1, "current_activations": 1 },
  "meta": { "request_id": "req_9f2c1a7b4d6e" }
}

POST /license/validate

The endpoint a running installation calls on start-up and periodically thereafter.

{
  "license_key": "XXXX-XXXX-XXXX-XXXX-XXXX",
  "product_code": "MYCRM",
  "domain": "example.com",
  "installation_id": "inst_8f72d91c4e6b0a35c7d2e19f4b8a06cd",
  "version": "2.4.1"
}

The response body is flat — not wrapped in a data envelope — so it reads exactly as published:

{
  "success": true,
  "valid": true,
  "status": "active",
  "product": "MYCRM",
  "license_type": "subscription",
  "expires_at": "2027-12-31",
  "installation_id": "inst_8f72d91c4e6b0a35c7d2e19f4b8a06cd",
  "domain": "example.com",
  "update_available": true,
  "latest_version": "2.5.0",
  "offline_until": "2026-10-06T12:00:00+00:00",
  "grace_expires_in": 259200,
  "certificate": "<compact Ed25519 token>",
  "public_key_id": "key_2026_01",
  "meta": { "request_id": "req_9f2c1a7b4d6e" }
}

An unusable license returns HTTP 200 with valid: false:

{
  "success": true,
  "valid": false,
  "status": "expired",
  "message": "The license has expired.",
  "error_code": "LICENSE_EXPIRED",
  "details": { "expired_at": "2026-10-01T00:00:00+00:00" },
  "meta": { "request_id": "req_9f2c1a7b4d6e" }
}

Unknown or mismatched products are caller errors and answer 404 / 403 instead.

POST /license/deactivate

Releases an activation slot. Supply installation_id or domain — never both missing, so an over-broad request cannot free every slot at once.

{
  "license_key": "XXXX-XXXX-XXXX-XXXX-XXXX",
  "product_code": "MYCRM",
  "installation_id": "inst_8f72d91c4e6b0a35c7d2e19f4b8a06cd",
  "reason": "Moved to a new server"
}
{
  "success": true,
  "deactivated": true,
  "installation": {
    "installation_id": "inst_8f72d91c4e6b0a35c7d2e19f4b8a06cd",
    "domain": "example.com",
    "status": "deactivated",
    "deactivated_at": "2026-10-03T12:05:00+00:00"
  },
  "remaining_activations": 1,
  "meta": { "request_id": "req_9f2c1a7b4d6e" }
}

A product may disable self-service deactivation; customers then receive DEACTIVATION_NOT_ALLOWED and must contact support.

POST /license/status

License state without touching an installation. Same body as validate, but the response contains only licence-level data — never a customer's domains or topology.

Domain rules

The reported domain is normalised before it is compared:

  • scheme, user:pass@, path, query, fragment and :port are stripped
  • www. is removed unless the product opts out
  • international domains are converted to punycode (bücher.example → xn--bcher-kva.example)
  • IPv4-mapped IPv6 collapses to plain IPv4 (::ffff:1.2.3.4 → 1.2.3.4)

Then:

  1. an explicit block list always wins
  2. environment gating applies — localhost, raw IPs and .test / .localstg. / dev. hosts are rejected unless the product enables them. Development domains are never trusted automatically.
  3. the allowlist is matched, including single-label wildcards such as *.example.com (which does not cover a.b.example.com unless the depth is raised)
  4. if a license has no configured domains and the product does not require an allowlist, any non-blocked host is accepted up to the domain limit

Development licenses

A development license type only activates against a development-classified domain while require_development_domain is on. This stops a staging licence from running on a customer's production domain.

Activated example — minimal middleware

$status = License::validate();

if (! $status->valid()) {
    abort(403, $status->message());
}

The SDK caches the certificate and only calls the server when it needs to.

Page 3 of 8