License Lifecycle
A license is not a static yes/no. It is a key bound to a product, carrying a seat limit, with zero or more domains activated against it at any moment. This page covers the three operations that move a license through its life — activate, validate, deactivate — plus seat limits and the meaning of every status.
The shape of a license
license key: CH-7K2P-9XQ4-LM83
product: acme-forms-pro
status: valid
expiresAt: 2027-06-04T00:00:00Z
seatLimit: 3
activations:
- example.com (activatedAt 2026-06-04T10:21:00Z) seat 1
- staging.example.com (activatedAt 2026-06-05T08:02:00Z) seat 2
- seat 3 free
A seat is one activated domain. seatLimit is how many seats the purchase bought. The activations array is the live list of domains currently holding seats.
Activate a domain
When a customer enters their key on a new site, claim a seat for that domain before unlocking anything.
curl -X POST https://api.code-heaven.com/v1/licenses/CH-7K2P-9XQ4-LM83/domains \
-H "X-CH-Vendor-Key: $CH_VENDOR_KEY" \
-H "Content-Type: application/json" \
-d '{"domain": "staging.example.com"}'
Success responds 201 with the refreshed activation list and seat limit:
{
"activated": true,
"domain": "staging.example.com",
"activations": [
{ "domain": "example.com", "activatedAt": "2026-06-04T10:21:00Z" },
{ "domain": "staging.example.com", "activatedAt": "2026-06-05T08:02:00Z" }
],
"seatLimit": 3
}
If every seat is already taken, you get 409:
{
"error": {
"code": "seat_limit_exceeded",
"message": "All 3 seats for this license are in use. Deactivate a domain to free one."
}
}
On 409, show the customer their active domains (you have them from the last validate call) and offer to deactivate one. Do not retry the activation — it will keep failing until a seat is freed.
Activating a domain that already holds a seat is idempotent: you get 201 and the existing activation, not a second seat. So a plugin that re-activates on every settings save will not burn seats.
Validate per domain
Activation claims the seat; validation confirms entitlement at runtime. Always validate against the current site's domain, because a license can be valid globally yet refused on a site that has no seat.
curl https://api.code-heaven.com/v1/licenses/validate \
-H "X-CH-Vendor-Key: $CH_VENDOR_KEY" \
-H "Content-Type: application/json" \
-d '{"licenseKey":"CH-7K2P-9XQ4-LM83","domain":"staging.example.com","product":"acme-forms-pro"}'
{
"valid": true,
"status": "valid",
"product": "acme-forms-pro",
"expiresAt": "2027-06-04T00:00:00Z",
"activations": [
{ "domain": "example.com", "activatedAt": "2026-06-04T10:21:00Z" },
{ "domain": "staging.example.com", "activatedAt": "2026-06-05T08:02:00Z" }
],
"seatLimit": 3
}
The natural order in a plugin is activate, then validate. If you validate first on a fresh site you will get domain_not_activated; treat that as "activate now, then re-validate," not as an error to show the customer:
<?php
$result = ch_validate_license($key, $domain, 'acme-forms-pro');
if ($result['status'] === 'domain_not_activated') {
$activation = ch_activate_domain($key, $domain); // POST .../domains
if (($activation['activated'] ?? false) === true) {
$result = ch_validate_license($key, $domain, 'acme-forms-pro'); // re-check
}
}
$unlocked = ($result['status'] ?? '') === 'valid';
Deactivate a domain
When a customer retires a site, migrates to a new domain, or deactivates your plugin, release the seat so they can use it elsewhere.
curl -X DELETE \
https://api.code-heaven.com/v1/licenses/CH-7K2P-9XQ4-LM83/domains/staging.example.com \
-H "X-CH-Vendor-Key: $CH_VENDOR_KEY"
{
"deactivated": true,
"domain": "staging.example.com"
}
Hook this into your plugin's deactivation routine and into a "deactivate this site" button in your settings UI:
<?php
register_deactivation_hook(__FILE__, function () {
$key = get_option('acme_license_key', '');
$domain = parse_url(home_url(), PHP_URL_HOST);
if ($key) {
ch_deactivate_domain($key, $domain); // DELETE .../domains/{domain}
}
});
Releasing a seat the customer is done with is good citizenship: it prevents support tickets that begin with "I moved my site and now it says all seats are used."
Seat limits, summarized
seatLimitcomes back on everyvalidateand everyactivateresponse. Read it to render "2 of 3 sites active."- Activation past the limit returns
409 seat_limit_exceeded. The fix is always to deactivate a domain first. - Re-activating an already-active domain is free (idempotent), so guard rails like "activate on save" are safe.
- A higher tier or an add-on purchase raises the limit; the change is reflected in the next
validateresponse — your plugin does not need to do anything special.
What every status means
validate returns a status that fully describes the license's standing. Map each one to a clear plugin behaviour:
| status | valid | Cause | Recommended plugin behaviour |
|---|---|---|---|
| valid | true | Paid, active, this domain activated | Unlock premium features |
| invalid | false | Key does not exist or is wrong | "Invalid license key" — prompt re-entry, don't retry |
| expired | false | The license term has lapsed | Lock premium features, prompt renewal, surface expiresAt |
| domain_not_activated | false | Key is fine but this site has no seat | Activate the domain, then re-validate |
| revoked | false | Cancelled by refund, chargeback, or abuse | Lock features, stop retrying, no renewal prompt |
Status vs. HTTP code
The validate endpoint returns 200 even when the license is not usable — the status lives in the body. The dedicated error codes (403 license_invalid, 403 expired, 403 domain_not_activated) appear on the other endpoints (activate, updates, download) when you try to act on a license that is not entitled. So:
- On
validate: read the body'sstatus. - On activate / updates / download: a
403means the license is not entitled to that action; theerror.codetells you which condition.
{
"error": {
"code": "domain_not_activated",
"message": "This domain is not activated for the license. Activate it first."
}
}
With the lifecycle handled, wire up updates and downloads so customers can stay current.