License Server

Issue licence keys when an order is paid, and let your own software verify, activate and deactivate them through a signed JSON API.

The License Server plugin sells software licences. When a customer pays for a product that uses it, the plugin issues a key. Your software sends that key to your billing installation to check it is valid, and can check the answer offline using a signature.

Set it up#

  1. Open Plugins and press Enable on License Server. Its tables are created and an Ed25519 signing key pair is generated.
  2. Press Settings on the plugin (optional):
    • Default grace days: days a suspended licence keeps validating. Products can override it. Default 0.
    • Verify updates "last seen": records an activation as seen each time its software calls verify. On by default.
  3. Create a product and set Module to Software licence. Fill in:
    • Product code: what your software sends as product. Blank uses the product's slug.
    • Key prefix: up to 10 letters or digits, for example ACME. Blank gives LIC.
    • Max activations: how many identifiers may use one key. Default 1.
    • Bind activations to: Domain, IP address, Instance id or Nothing (key only). Default is Domain.
    • Grace days after suspension: blank uses the plugin default.

When the order is paid, a key such as ACME-7KQ2-XM4D-R9TP-H3NA is issued. It shows on the service page for you and the customer, and under Licenses in the admin menu. The last group of a key is a checksum, so mistyped keys are rejected before any lookup.

What happens over the life of a service#

Service event Licence
Paid and activated Key issued (a second activation of the same service reuses the key)
Suspended (for example an overdue invoice) Suspended; keeps validating as grace while grace days remain
Unsuspended Active again
Terminated Revoked
Plan changed Max activations, binding, product code and grace days are re-read from the product

Manage licences#

Licenses lists keys with product, client, status, activations used and expiry, and filters by status (Active, Suspended, Revoked). Open a key to:

  • Revoke it (software stops validating) or Reactivate it.
  • Change Max activations.
  • Set Expires on, or extend by a number of days with …or extend by (days), or tick Remove expiry date. With no date, the licence follows the billing status.
  • Remove an activation, for example when a customer moves to a new domain. Customers can remove their own activations on their service page.

The page also shows the Verification key: the public key to embed in your software.

The API#

Public JSON endpoints, no token needed. The base is /api/v1/x/license-server.

Method Path Purpose
POST /verify Check that a key is valid for this identifier
POST /activate Register an identifier against the key
POST /deactivate Remove an identifier
GET /public-key {"algorithm": "ed25519", "public_key": "<base64>"}

The three POST calls take key and product (both required), and optionally identifier and version.

  • Identifier. For domain binding it is a domain, normalised by removing the scheme, www., port and path and lowercasing. For IP binding it is an IP address and defaults to the caller's. For instance binding it is any string up to 191 characters. With Nothing (key only) no identifier is needed.
  • Activate is safe to repeat: activating the same identifier twice changes nothing.
  • Verify requires that identifier to be activated first.
  • Deactivate answers {"deactivated": true, "activations": {"used": 0, "max": 2}}.
curl -X POST https://billing.example.com/api/v1/x/license-server/activate \
  -H 'Content-Type: application/json' \
  -d '{"key":"ACME-7KQ2-XM4D-R9TP-H3NA","product":"acme-pro","identifier":"example.com","version":"2.1.0"}'

A success looks like this:

{
  "valid": true,
  "status": "active",
  "expires_at": null,
  "issued_at": 1700000000,
  "activations": {"used": 1, "max": 2},
  "signature": "<base64>"
}

status is active or grace. expires_at is an ISO 8601 date or null.

Errors#

Failures return {"valid": false, "error": {"code": "...", "message": "..."}}.

HTTP code Meaning
403 invalid Unknown key, malformed key or wrong product: all answer the same way, so callers learn nothing
403 not_activated The key is not activated for that identifier
403 suspended, revoked, expired The licence is not usable
409 activation_limit The maximum number of activations is used
422 identifier_required The binding needs an identifier and none valid was sent

Rate limits#

Each IP address and key combination may make 30 requests a minute, and each IP address 120 a minute across all keys.

Verify the answer offline#

Each success is signed with Ed25519. The signed text is these lines joined by newline characters:

license.v1
valid=1
key=<key>
product=<product code>
status=<status>
expires=<expires_at, or empty>
identifier=<normalised identifier, or empty>
issued=<issued_at>

Use the public key from Licenses or GET /public-key. This PHP function checks a response; it also applies your own freshness window, here seven days:

function licenseIsValid(array $r, string $key, string $product, string $identifier, string $publicKeyB64): bool
{
    $payload = implode("\n", [
        'license.v1', 'valid=1', "key=$key", "product=$product", 'status='.$r['status'],
        'expires='.($r['expires_at'] ?? ''), "identifier=$identifier", 'issued='.$r['issued_at'],
    ]);
    $fresh = time() - $r['issued_at'] < 7 * 86400;

    return $fresh && sodium_crypto_sign_verify_detached(base64_decode($r['signature']), $payload, base64_decode($publicKeyB64));
}

Use the normalised identifier (lowercase host) in the payload, and cache the last good response so your software can run offline for as long as you allow.

For developers#

The plugin fires Plugins\LicenseServer\Events\LicenseRevoked, which you can listen to, and the license-server.response filter lets another plugin add (unsigned) fields to responses. See Writing a plugin.

Was this article helpful?
Last updated 10 Nov 2026
Powered by Tidemark Billing