Skip to main content

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

  • seatLimit comes back on every validate and every activate response. 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 validate response — 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:

statusvalidCauseRecommended plugin behaviour
validtruePaid, active, this domain activatedUnlock premium features
invalidfalseKey does not exist or is wrong"Invalid license key" — prompt re-entry, don't retry
expiredfalseThe license term has lapsedLock premium features, prompt renewal, surface expiresAt
domain_not_activatedfalseKey is fine but this site has no seatActivate the domain, then re-validate
revokedfalseCancelled by refund, chargeback, or abuseLock 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's status.
  • On activate / updates / download: a 403 means the license is not entitled to that action; the error.code tells 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.