License API
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:portare 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:
- an explicit block list always wins
- environment gating applies —
localhost, raw IPs and.test/.localstg. /dev.hosts are rejected unless the product enables them. Development domains are never trusted automatically. - the allowlist is matched, including single-label wildcards such as
*.example.com(which does not covera.b.example.comunless the depth is raised) - 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