Complete reference · generated 2026-11-06 18:13:21
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.
Every request concerns one of four things:
A license is issued in a single action and then activated once per running copy.
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.
| 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 |
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.
Read Authentication, then License API.
Introduction · https://license.shaishad.com/docs/introduction
Every licensing endpoint except /ping and /products/{code}/public-key requires authentication. Two interchangeable schemes are supported.
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.
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.
STALE_REQUEST; call /ping to measure the offset.REPLAY_DETECTED.Together these make a captured request worthless within five minutes.
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.
| 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.
A key created for a product may only act on licenses belonging to that product. A key created without a product is platform-wide.
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.
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.
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
All four endpoints require authentication. See Authentication.
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.
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": 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" }
}
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" }
}
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.
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.
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.
The reported domain is normalised before it is compared:
user:pass@, path, query, fragment and :port are strippedwww. is removed unless the product opts outbücher.example → xn--bcher-kva.example)::ffff:1.2.3.4 → 1.2.3.4)Then:
localhost, raw IPs and .test / .localstg. / dev. hosts are rejected unless the product enables them. Development domains are never trusted automatically.*.example.com (which does not cover a.b.example.com unless the depth is raised)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.
$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 and packages are only served to installations that have proved entitlement on this request — never because a key was presented once.
{
"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.
stable, beta, alpha, security, hotfix.
Pre-releases are never offered unless the product enables them or the request sets include_pre_releases: true.
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.
{
"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" }
}
Packages are stored on a private disk with serving disabled. There is no public URL for a .zip anywhere in the system.
Each redemption writes a downloads row recording product, version, license, customer, installation, IP, user agent and bytes served.
Always verify the checksum before extracting:
$expected = '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08';
$actual = hash_file('sha256', $downloadedPath);
if (! hash_equals($expected, $actual)) {
throw new RuntimeException('Package checksum mismatch.');
}
The server never overwrites a customer's files. For a one-click update flow:
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
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 | 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.
| 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 |
| Code | Meaning |
|---|---|
PRODUCT_NOT_FOUND |
No product with that code |
PRODUCT_MISMATCH |
The license belongs to a different product |
| 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 |
| 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 |
| 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 |
| 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 |
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
Receive licence and release events instead of polling.
license.created · license.activated · license.deactivated · license.expired · license.suspended · license.revoked · license.blocked · license.restored · installation.deactivated · software.released · software.downloaded · security.alert
{
"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..." }
}
}
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.
| 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.
$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);
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
licensify/client-sdk is a dependency-free PHP package that turns licensing into a single call.
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.
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 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
composer.json and composer require licensify/client-sdk:1.0.0.License::validate() once, in a middleware.No change to your controllers, models or routes.
Every successful validation returns a certificate: a compact token signed with the server's Ed25519 private key.
nbf and exp on its own clock.rev counter.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.
rev counter is bumped whenever a license is suspended, revoked or changed, so a stale cached certificate is rejected at the next contact.The SDK never treats "I could not reach the server" as "valid".
Certificates name the key that signed them in a kid header. When the server rotates:
GET /api/v1/products/{code}/public-key.Recover from UNKNOWN_SIGNING_KEY by refreshing the keyring:
License::refreshKeyring();
No new SDK release is needed.
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
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
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.
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.
| 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 |
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.
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:
api_requests reaches hundreds of millions of rows.| 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.
key_hash — never a table scan.api_requests is written by a queued job so validation never waits on analytics writes.The cached-status path still honours revocation: every state change bumps the license's offline_revocation_counter and clears its cache entry.
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