Code HeavenCode Heaven
Developer API

License Lifecycle: Activation, Validation, and Seats

Manage the full Code Heaven license lifecycle: activate a domain, validate per domain, deactivate a domain, and handle seat limits. Learn what every license status means and how to react.

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:

| 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'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.