Licensify API Documentation

Complete reference · generated 2026-11-06 18:13:21

Introduction

The licensing server is the single source of truth for every product you sell. One installation manages products, customers, licenses, activations, versions, downloads and security — and each of your PHP products talks to it through a small, stable API.

The model

Every request concerns one of four things:

A license is issued in a single action and then activated once per running copy.

Base URL

All endpoints live under a version prefix:

https://license.example.com/api/v1

v1 is frozen. When a breaking change is unavoidable, v2 is introduced alongside it and existing installations keep working untouched.

Endpoints at a glance

Method Path Purpose
GET /ping Liveness and server clock
GET /products/{code}/public-key Signing keys for the SDK keyring
POST /license/activate Register an installation
POST /license/validate Decide whether software may run
POST /license/deactivate Release an activation slot
POST /license/status Read license state only
POST /updates/check Is there a newer release?
POST /downloads/request Get a temporary download URL
GET /downloads/{token} Stream a package

Design rules that never change

The server decides. A customer installation never decides for itself. It reports facts — its license key, domain, installation id, version — and obeys the answer.

A license key is not a credential. It identifies what is being authorised. Who is calling is established separately, with an API key and a request signature. See Authentication.

Deleting local state does not grant access. Removing a cached certificate forces an immediate re-validation. If the server cannot be reached, the installation is blocked once its grace window closes — it never fails open.

Offline is bounded, and the bound is server-controlled. Every successful validation returns a signed certificate valid for the product's grace period. The length of that window is decided on the server and travels inside the certificate.

Next

Read Authentication, then License API.

Introduction · https://license.shaishad.com/docs/introduction

Authentication

Every licensing endpoint except /ping and /products/{code}/public-key requires authentication. Two interchangeable schemes are supported.

Why a license key is not enough

A license key is a secret the customer holds, not a credential the server can verify. If it were the only authentication, anyone who scraped one key from a customer's .env could validate, download and probe every other license in the system.

So the two are kept strictly separate:

Both are required. Neither substitutes for the other.

Scheme A — HMAC request signing (recommended)

Four headers accompany the request:

X-License-Key:       lk_a1b2c3d4e5
X-License-Timestamp: 1791019200
X-License-Nonce:     8fj2Kd0pQmR7x1vNb3sL
X-License-Signature: <base64url HMAC>

The signature is computed over a canonical string of exactly six LF-joined lines, with no trailing newline:

<METHOD>
<path>
<canonical query string, or empty>
<sha256 hex of the raw request body>
<X-License-Timestamp>
<X-License-Nonce>

For example, signing POST /api/v1/license/activate with body {"a":1}:

POST
/api/v1/license/activate

9f4e7c...                                  <- sha256 of {"a":1}
1791019200
8fj2Kd0pQmR7x1vNb3sL

then

signature = base64url(hmac_sha256(api_secret, canonical_string))

Canonical query string. Query parameters are sorted by key, then the pairs are sorted again as whole strings, then joined with &. This makes the signature independent of parameter order, so a proxy that reorders them cannot invalidate a legitimate request.

Replay protection

Together these make a captured request worthless within five minutes.

Scheme B — Bearer

For curl, CI and quick integrations, a key created with signing not required accepts:

Authorization: Bearer <api_key>.<api_secret>

The secret is compared against a stored SHA-256 in constant time. Keys created with require_signature enabled — the default — reject this scheme and return INVALID_SIGNATURE, which is the safer default for production products.

Scopes

Scope Grants
license:validate POST /license/validate
license:activate POST /license/activate
license:deactivate POST /license/deactivate
license:status POST /license/status
updates:check POST /updates/check
download:request POST /downloads/request
products:read Product metadata reads

Grant the narrowest set that works. A key holding only license:validate cannot reach the download endpoint.

Product scoping

A key created for a product may only act on licenses belonging to that product. A key created without a product is platform-wide.

IP allowlist

An optional allowlist on the key restricts it to specific addresses. It is an absolute gate, checked before the signature, and returns IP_NOT_ALLOWED.

Rate limits

Every request is counted against several buckets simultaneously — IP, API key, license, product and installation. The tightest one wins, so rotating IP addresses or installation ids does not evade a limit.

Bucket Default
IP 120 / min
API key 120 / min
License 60 / min
Product 600 / min
Installation 60 / min
Activation 10 / min
Download 10 / min

Responses carry X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Dimension and, when limited, Retry-After.

TLS

The API refuses plain HTTP in production. Signing keys, license keys and installation metadata are all transmitted in these bodies; sending them in cleartext would void the whole scheme.

Authentication · https://license.shaishad.com/docs/authentication

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:

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.

License API · https://license.shaishad.com/docs/licenses

Updates & Downloads

Updates and packages are only served to installations that have proved entitlement on this request — never because a key was presented once.

POST /updates/check

{
  "product_code": "MYCRM",
  "license_key": "XXXX-XXXX-XXXX-XXXX-XXXX",
  "current_version": "2.4.1",
  "domain": "example.com",
  "installation_id": "inst_8f72d91c4e6b0a35c7d2e19f4b8a06cd"
}
{
  "success": true,
  "update_available": true,
  "current_version": "2.4.1",
  "latest_version": "2.5.0",
  "release_type": "stable",
  "release_notes": "Faster reporting, 40% smaller export jobs.",
  "published_at": "2026-09-28T09:00:00+00:00",
  "download_available": true,
  "changelog_url": "https://example.com/changelog/2.5.0",
  "requirements": {
    "minimum_php_version": "8.2",
    "minimum_laravel_version": "11.0",
    "requires_backup_acknowledgement": true,
    "requires_full_download": true
  },
  "meta": { "request_id": "req_9f2c1a7b4d6e" }
}

Version comparison is numeric per segment, so 2.10.0 correctly outranks 2.9.0.

Release types

stable, beta, alpha, security, hotfix.

Pre-releases are never offered unless the product enables them or the request sets include_pre_releases: true.

Requirement gating

If the installation's PHP or Laravel version is below the release minimum, the response keeps update_available: true but sets download_available: false and explains why. An installation that cannot run the release is never handed one.

POST /downloads/request

{
  "product_code": "MYCRM",
  "license_key": "XXXX-XXXX-XXXX-XXXX-XXXX",
  "version": "2.5.0",
  "domain": "example.com",
  "installation_id": "inst_8f72d91c4e6b0a35c7d2e19f4b8a06cd"
}
{
  "success": true,
  "url": "https://license.example.com/api/v1/downloads/3f9c...",
  "expires_at": "2026-10-03T12:10:00+00:00",
  "expires_in": 600,
  "filename": "MYCRM-2.5.0.zip",
  "version": "2.5.0",
  "size_bytes": 18453220,
  "checksum_algorithm": "sha256",
  "checksum": "sha256:9f86d081884c7d65...",
  "meta": { "request_id": "req_9f2c1a7b4d6e" }
}

How downloads are protected

Packages are stored on a private disk with serving disabled. There is no public URL for a .zip anywhere in the system.

  1. Entitlement is re-proved at grant time.
  2. A single-use token — 192 bits of CSPRNG output, carrying no data of its own — is recorded against the package, license and installation with a short TTL.
  3. The token is re-checked at redemption time: expiry, license status and installation status are all re-read. A license suspended between grant and download cannot fetch the bytes.
  4. The stream is chunked, so a large archive is never buffered in memory.

Each redemption writes a downloads row recording product, version, license, customer, installation, IP, user agent and bytes served.

Verifying the package

Always verify the checksum before extracting:

$expected = '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08';
$actual   = hash_file('sha256', $downloadedPath);

if (! hash_equals($expected, $actual)) {
    throw new RuntimeException('Package checksum mismatch.');
}

Safe updating

The server never overwrites a customer's files. For a one-click update flow:

  1. Back up — archive the current tree and the database.
  2. Verify — checksum the downloaded package.
  3. Stage — extract to a temporary directory, not over the live one.
  4. Swap — move the old tree aside, move the new one in.
  5. Roll back on failure — restore the previous directory and re-run the migration rollback.

requires_backup_acknowledgement is true by default so a UI can insist on step 1 before offering the update button.

Updates & Downloads · https://license.shaishad.com/docs/updates

Error Codes

Error envelope

Every hard failure uses one shape:

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

Always log the request_id. It is the only thing that ties a customer's report to the server's structured logs.

Status codes

Status Meaning
200 The call succeeded — including a refused license
401 Authentication failed
403 Authenticated but not entitled
404 No such product, endpoint or release
422 The request body failed validation
426 The SDK is too old for this endpoint
429 Rate limited; see Retry-After
500 Unexpected server fault

A refused license is not an error status. license/validate and license/activate answer 200 with success: false and an error_code, because the transport succeeded and the licence did not. Handling this keeps customer software's error paths simple.

License codes

Code Meaning
INVALID_LICENSE The key is not recognised
LICENSE_EXPIRED Past expires_at
LICENSE_SUSPENDED Temporarily disabled by an administrator
LICENSE_REVOKED Permanently withdrawn
LICENSE_BLOCKED Withheld, e.g. after a chargeback
LICENSE_INACTIVE Still pending, or the installation is deactivated
DEACTIVATION_NOT_ALLOWED Self-service deactivation is disabled

Product codes

Code Meaning
PRODUCT_NOT_FOUND No product with that code
PRODUCT_MISMATCH The license belongs to a different product

Domain and activation codes

Code Meaning
DOMAIN_NOT_ALLOWED Not in the allowlist, or an environment the product forbids
DOMAIN_BLOCKED Explicitly blocked for this license
ACTIVATION_LIMIT_REACHED All activation slots are in use
DOMAIN_LIMIT_REACHED All domain slots are in use
INSTALLATION_NOT_FOUND No matching installation on this license
IP_NOT_ALLOWED The caller's IP is not on the allowlist

Authentication codes

Code Meaning
INVALID_API_KEY Unknown, revoked or expired key
INVALID_SIGNATURE HMAC mismatch, or the key requires signing
REPLAY_DETECTED The nonce was already used
STALE_REQUEST Timestamp outside the permitted window
RATE_LIMIT_EXCEEDED Too many requests

Distribution codes

Code Meaning
DOWNLOAD_NOT_AUTHORIZED No valid entitlement for this package
DOWNLOAD_UNAVAILABLE No published package for that version
UPDATE_NOT_AVAILABLE Already on the latest release

Platform codes

Code Meaning
VALIDATION_FAILED The body failed validation
HTTPS_REQUIRED The call did not arrive over TLS
NOT_FOUND Unknown endpoint
SERVER_ERROR Unexpected fault — quote the request id
MAINTENANCE_MODE Temporarily offline
SDK_TOO_OLD Upgrade the client SDK

Certificate codes

Returned by the SDK when a cached certificate cannot be trusted:

Code Meaning
INVALID_CERTIFICATE Malformed or wrong token type
INVALID_SIGNATURE Signature does not verify
UNKNOWN_SIGNING_KEY kid is not in the keyring — refresh it
UNSUPPORTED_ALGORITHM Only ed25519 is accepted
CERTIFICATE_NOT_YET_VALID Clock skew
LICENSE_GRACE_EXPIRED Offline window closed; the server must be reachable

UNKNOWN_SIGNING_KEY usually means the server rotated its signing key. Fetch the keyring again from /products/{code}/public-key — this is the supported recovery path and needs no new SDK release.

Error Codes · https://license.shaishad.com/docs/errors

Webhooks

Receive licence and release events instead of polling.

Available events

license.created · license.activated · license.deactivated · license.expired · license.suspended · license.revoked · license.blocked · license.restored · installation.deactivated · software.released · software.downloaded · security.alert

Payload

{
  "id": "0f7c1d2e-8a3b-4c5d-9e6f-1a2b3c4d5e6f",
  "event": "license.activated",
  "created_at": "2026-10-03T12:00:00+00:00",
  "api_version": "v1",
  "data": {
    "license": { "id": "01J8Z...", "status": "active", "product": "MYCRM" },
    "customer": { "id": "01J8Y...", "email": "[email protected]", "name": "Northwind Traders" },
    "activation": { "domain": "example.com", "installation_id": "inst_8f72d91c..." }
  }
}

Verifying the signature

Three headers accompany every delivery:

X-Webhook-Id:        0f7c1d2e-8a3b-4c5d-9e6f-1a2b3c4d5e6f
X-Webhook-Event:     license.activated
X-Webhook-Timestamp: 1791019200
X-Webhook-Signature: sha256=<hex hmac>

The signed payload is the timestamp, a full stop, then the raw request body:

$signature = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);

if (! hash_equals($signature, $header)) {
    http_response_code(401);
    exit;
}

Binding the timestamp prevents a captured delivery being replayed later. Reject anything outside a five-minute window.

Always compare the raw body — re-encoding the JSON changes the bytes and breaks verification.

The signing secret is shown once when the endpoint is created and is encrypted at rest afterwards. Rotate it from the admin screen if it leaks.

Retries

Attempt Delay
1 immediate
2 1 minute
3 5 minutes
4 15 minutes
5 1 hour
6 6 hours

After the last attempt the delivery is marked failed and stays visible in the admin UI. It is not retried indefinitely — return a 2xx as soon as you have persisted the event, and process it asynchronously.

A response is treated as success on any 2xx.

Example receiver

$raw     = file_get_contents('php://input');
$event   = $_SERVER['HTTP_X_WEBHOOK_EVENT'] ?? '';
$secret  = getenv('WEBHOOK_SECRET');
$ts      = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';

if (abs(time() - (int) $ts) > 300) {
    http_response_code(401);
    exit;
}

$expected = 'sha256='.hash_hmac('sha256', $ts.'.'.$raw, $secret);

if (! hash_equals($expected, $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '')) {
    http_response_code(401);
    exit;
}

// Acknowledge quickly; process afterwards so a slow handler is not retried.
$id = $_SERVER['HTTP_X_WEBHOOK_ID'];
file_put_contents('/tmp/webhook-queue', $id."\n", FILE_APPEND);

http_response_code(204);

Deduplication

X-Webhook-Id is stable across retries for the same delivery. Record it and ignore repeats — a retried delivery is not a new event.

Webhooks · https://license.shaishad.com/docs/webhooks

Client SDK

licensify/client-sdk is a dependency-free PHP package that turns licensing into a single call.

Install

The SDK lives in its own repository — syedshaishad/licensify-client-sdk — and is installed from there. Add the repository to your composer.json:

{
    "repositories": [
        {
            "type": "git",
            "url": "https://github.com/syedshaishad/licensify-client-sdk.git"
        }
    ]
}

A git entry rather than a vcs one on purpose: vcs asks the GitHub API for the repository's details first, and the API answers unauthenticated requests only sixty times an hour. Behind a shared address that runs out, and the failure is a confusing message about SSH credentials. A git entry clones over HTTPS and never touches the API.

Then:

composer require licensify/client-sdk:1.0.0

Requires PHP 8.3+ with ext-sodium and ext-json. The package has no runtime dependencies, so nothing else is pulled in.

Configure

LICENSE_SERVER_URL=https://license.example.com
LICENSE_PRODUCT_CODE=MYCRM
LICENSE_KEY=XXXX-XXXX-XXXX-XXXX-XXXX
LICENSE_API_KEY=lk_a1b2c3d4e5
LICENSE_API_SECRET=your-api-secret

The SDK derives everything else by itself: it generates and persists an installation id, detects the current domain, and reads the running version.

Use

use Licensify\Client\License;

$status = License::validate();

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

In a route middleware:

use Licensify\Client\Laravel\Http\Middleware\EnsureLicenseIsValid;

// routes/web.php
Route::middleware(EnsureLicenseIsValid::class)->group(function () {
    // ...
});

Other calls:

License::activate();               // register this installation
License::deactivate('new server'); // release the slot
License::status();                 // licence state, no installation
License::updateAvailable();        // ?UpdateInfo
License::valid();                  // bool
License::certificate();            // the cached token, for inspection
License::graceExpiresAt();         // ?DateTimeImmutable
License::expireCache();            // force re-validation

Integrating in four steps

  1. Create the product in the dashboard.
  2. Add the repository to composer.json and composer require licensify/client-sdk:1.0.0.
  3. Add the four environment variables.
  4. Call License::validate() once, in a middleware.

No change to your controllers, models or routes.

How offline validation works

Every successful validation returns a certificate: a compact token signed with the server's Ed25519 private key.

  1. The SDK verifies the signature against the public keys it trusts.
  2. It checks nbf and exp on its own clock.
  3. It records the certificate's rev counter.
  4. While the certificate is valid it answers without contacting the server.

When the server is unreachable the SDK falls back to the cached certificate for the remainder of the grace window. When that window closes it fails closed.

Why deleting the cache does not help an attacker

The SDK never treats "I could not reach the server" as "valid".

Key rotation

Certificates name the key that signed them in a kid header. When the server rotates:

  1. It promotes a new key and keeps the previous one published.
  2. SDKs fetch the current keyring from GET /api/v1/products/{code}/public-key.
  3. A certificate signed by a still-published retired key keeps verifying.

Recover from UNKNOWN_SIGNING_KEY by refreshing the keyring:

License::refreshKeyring();

No new SDK release is needed.

Framework support

The core is plain PHP and works anywhere — Laravel, CodeIgniter, a plain script. A Laravel-specific bridge ships separately:

// config/license.php
return [
    'server_url'      => env('LICENSE_SERVER_URL'),
    'product_code'    => env('LICENSE_PRODUCT_CODE'),
    'license_key'     => env('LICENSE_KEY'),
    'api_key'         => env('LICENSE_API_KEY'),
    'api_secret'      => env('LICENSE_API_SECRET'),
    'timeout'         => 10,
    'verify_ssl'      => true,
    'validate_on'     => ['request'],
];

The bridge registers a service provider, a License facade, the LicenseManager binding and the license:check Artisan command:

php artisan license:check

Testing against a local server

License::expireCache();
License::activate();          // must be reachable
License::validate();          // true
License::deactivate('test');

Because the SDK is fail-closed by design, a test suite that blocks network access will correctly report an invalid license. Inject a fake transport, or point LICENSE_SERVER_URL at a running instance.

Client SDK · https://license.shaishad.com/docs/sdk

Architecture

Layers

HTTP / CLI
    │
    ▼
Controllers · Form Requests · Policies · API Resources
    │
    ▼
Actions · Services (Licensing · Distribution · Security)
    │
    ▼
Models · Enums · Support

Business rules live in app/Services/Licensing. Controllers translate HTTP into DTOs, call a service, and render the result. Nothing in resources/views decides anything.

The licensing core

App\Services\Licensing\LicenseService implements App\Contracts\Licensing\LicenseManager:

$license = License::issue(IssueLicenseData::for($product, $customer)
    ->expiresAt('2027-12-31')
    ->maxDomains(1));

$license = $licenses->issue(new IssueLicenseData(
    product: $product,
    customer: $customer,
    expiresAt: Carbon::parse('2027-12-31'),
));

It is the only place that decides whether an installation may run. The HTTP API and any future product integration are thin adapters over it, so the rules cannot drift between entry points.

Supporting services

Service Responsibility
LicenseKeyGenerator CSPRNG key generation, uniqueness retry
DomainNormalizer Canonicalise and classify any host form
DomainGuard Single authority on domain permission
LicenseCertificateSigner Issue and verify Ed25519 certificates
SigningKeyManager Provision, rotate and publish signing keys
ApiRequestVerifier Bearer/HMAC authentication, replay protection
ApiRateLimiter Composite rate limiting
PackageValidator ZIP inspection before storage
ReleaseService Version and package authoring
DownloadService Temporary signed URLs
UpdateService Update eligibility
SecurityAlertService Heuristic alerts
AuditLogger The immutable trail
DashboardService Read-only aggregation

Adding a product

Creating a product requires no change to the licensing core. Everything the core reads — grace period, activation limits, domain policy, release gating — lives on the product row and is already read generically.

Data model

products ──┬── product_versions ──── release_packages
           ├── api_keys
           ├── webhook_endpoints
           │
customers ─┴── licenses ──┬── allowed_domains
                          ├── installations ──── license_activations
                          ├── license_events
                          └── downloads

Cross-cutting: api_requests · audit_logs · security_events · settings · signing_keys

Identifiers:

Storage of secrets

Secret Storage
License key encrypted cast + SHA-256 for lookups + peppered fingerprint
API secret SHA-256 for Bearer compare + encrypted copy for HMAC verify
Signing private key encrypted cast only; never serialised
Webhook secret encrypted cast, shown once
SMTP password encrypted at rest
LICENSIFY_KEY_PEPPER Environment only — never in the database

APP_KEY and LICENSIFY_KEY_PEPPER must be backed up separately. Losing them means every encrypted column is unreadable and every license fingerprint must be recomputed.

Performance

The cached-status path still honours revocation: every state change bumps the license's offline_revocation_counter and clears its cache entry.

Extending to multiple vendors

The schema already isolates products, and every model that matters carries a product_id. Before offering this to third parties you would add an organizations table and a nullable organization_id to products, API keys and webhook endpoints. The licensing core itself needs no change — it resolves policy through the product, never through a hardcoded vendor.

Architecture · https://license.shaishad.com/docs/architecture