You sell a template once, the buyer downloads the ZIP, and a week later the same ZIP is running on six domains. You do not need heavy DRM to deal with that. You need a key nobody can guess, an activation record that counts seats, and a signed token the buyer's site can verify locally instead of calling your server on every page load.
Below is a runnable version of that flow: a small licence server in PHP, a client plugin that registers the domain and stores the signed token, and the decisions you have to make before writing any of it.
Decide what one licence means first
| Model | What the customer gets | What you can check automatically | Where it hurts |
|---|---|---|---|
| Per domain | One live domain plus staging | Domain in the activation request | An agency with four client sites needs four keys |
| Per seat | N domains | Count of activation rows | Deactivate/reactivate cycles turn into support tickets |
| Per organisation | Unlimited domains, one company | Almost nothing | Only goodwill |
| Time-boxed | Updates for 12 months |
exp inside the token |
Reads like a subscription if you word it badly |
Per domain with one free staging domain is the easiest to explain and the easiest to enforce. Use the same sentence on the product page and in the activation error message, otherwise every support thread becomes a debate about what "a site" means.
The four moving parts
- Issuer - runs after your payment webhook. Creates the key, stores a hash, emails the plaintext once.
-
Activation endpoint - takes
key + domain, checks the seat count, writes an activation row, returns a signed token. - Token - signed with a private key, verified by the buyer's site with the public key. No network call needed for normal operation.
- Refresh - the site asks for a new token periodically. That request is also your revocation channel: refuse to renew, the old token expires, updates stop.
Key format and storage
Use Crockford base32: no I, L, O, U, so a key read out over the phone does not become a support ticket. Twenty characters is 100 bits of entropy, which is plenty for something that is also rate-limited.
<?php
function new_licence_key(): string
{
$alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; // 32 characters
$raw = random_bytes(20); // 256 % 32 === 0, so no modulo bias here
$out = '';
for ($i = 0; $i < 20; $i++) {
$out .= $alphabet[ord($raw[$i]) % 32];
}
return 'TPL-' . implode('-', str_split($out, 5));
}
Never store the key itself. It is high-entropy random, so a fast HMAC is enough - this is not a password and you do not need a slow password hash.
function key_hash(string $normalised): string
{
return hash_hmac('sha256', $normalised, PEPPER); // PEPPER from env, never in the repo
}
function normalise_key(string $key): string
{
return preg_replace('/[^0-9A-Z]/', '', strtoupper($key));
}
Keep a key_prefix column with the first twelve characters so support can find a licence when the customer only forwards a screenshot of the email.
CREATE TABLE licenses (
id INTEGER PRIMARY KEY,
key_prefix TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
order_ref TEXT NOT NULL UNIQUE, -- makes the payment webhook idempotent
product TEXT NOT NULL,
seats INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL
);
CREATE TABLE activations (
id INTEGER PRIMARY KEY,
license_id INTEGER NOT NULL REFERENCES licenses(id),
domain TEXT NOT NULL,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
UNIQUE (license_id, domain)
);
UNIQUE (license_id, domain) is the entire seat-counting logic. A customer who submits the same domain twice does not burn a second seat.
Normalise the domain or you will count www twice
function normalise_domain(string $input): string
{
$d = strtolower(trim($input));
$d = preg_replace('#^https?://#', '', $d);
$d = explode('/', $d)[0];
$d = explode(':', $d)[0];
$d = rtrim($d, '.');
if (function_exists('idn_to_ascii')) {
$d = idn_to_ascii($d, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46) ?: $d;
}
return str_starts_with($d, 'www.') ? substr($d, 4) : $d;
}
function is_dev_domain(string $d): bool
{
return $d === 'localhost'
|| $d === '127.0.0.1'
|| str_ends_with($d, '.test')
|| str_ends_with($d, '.local')
|| str_ends_with($d, '.localhost');
}
Dev domains get a token but no activation row, so rebuilding a local copy never eats a seat. Put that on the pricing page as well - it is the difference between "reasonable" and "hostile".
The activation server
Generate a signing keypair once and keep the secret half in the environment:
$kp = sodium_crypto_sign_keypair();
echo sodium_bin2hex(sodium_crypto_sign_secretkey($kp)), PHP_EOL; // -> LICENSE_SIGN_SK
echo sodium_bin2hex(sodium_crypto_sign_publickey($kp)), PHP_EOL; // -> ship this in the client
Then one file, SQLite, no framework. Run it with php -S 127.0.0.1:8080 server.php.
<?php
declare(strict_types=1);
const PEPPER = 'replace-me';
$sk = sodium_hex2bin(getenv('LICENSE_SIGN_SK') ?: '');
$pdo = new PDO('sqlite:' . __DIR__ . '/licenses.sqlite', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$pdo->exec('PRAGMA journal_mode=WAL');
function json_out(array $data, int $code = 200): void
{
http_response_code($code);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
function b64url(string $bin): string
{
return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
}
function used_seats(PDO $pdo, int $licenceId): int
{
$st = $pdo->prepare('SELECT COUNT(*) FROM activations WHERE license_id = ?');
$st->execute([$licenceId]);
return (int) $st->fetchColumn();
}
function issue_token(array $lic, string $domain, string $sk): string
{
$payload = json_encode([
'v' => 1,
'lid' => (int) $lic['id'],
'key' => $lic['key_prefix'],
'dom' => $domain,
'exp' => time() + 14 * 86400,
'feat' => ['updates', 'support'],
], JSON_UNESCAPED_SLASHES);
return b64url($payload) . '.' . b64url(sodium_crypto_sign_detached($payload, $sk));
}
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$input = json_decode(file_get_contents('php://input') ?: '[]', true) ?: [];
$key = normalise_key($input['key'] ?? '');
$dom = normalise_domain($input['domain'] ?? '');
if ($path !== '/activate' && $path !== '/refresh') {
json_out(['error' => 'not_found'], 404);
}
if ($key === '' || $dom === '') {
json_out(['error' => 'missing_fields'], 422);
}
$st = $pdo->prepare('SELECT * FROM licenses WHERE key_hash = ?');
$st->execute([hash_hmac('sha256', $key, PEPPER)]);
$lic = $st->fetch(PDO::FETCH_ASSOC);
if (!$lic) {
json_out(['error' => 'unknown_key'], 404);
}
if ($lic['status'] !== 'active') {
json_out(['error' => 'licence_' . $lic['status']], 403);
}
if (is_dev_domain($dom)) {
json_out(['token' => issue_token($lic, $dom, $sk), 'seats' => (int) $lic['seats'], 'seats_used' => used_seats($pdo, (int) $lic['id']), 'dev' => true]);
}
$st = $pdo->prepare('SELECT id FROM activations WHERE license_id = ? AND domain = ?');
$st->execute([$lic['id'], $dom]);
$activationId = $st->fetchColumn();
if ($activationId === false) {
if (used_seats($pdo, (int) $lic['id']) >= (int) $lic['seats']) {
json_out(['error' => 'seat_limit_reached', 'seats' => (int) $lic['seats']], 409);
}
$now = gmdate('c');
$pdo->prepare('INSERT INTO activations (license_id, domain, first_seen, last_seen) VALUES (?,?,?,?)')
->execute([$lic['id'], $dom, $now, $now]);
} else {
$pdo->prepare('UPDATE activations SET last_seen = ? WHERE id = ?')
->execute([gmdate('c'), $activationId]);
}
json_out([
'token' => issue_token($lic, $dom, $sk),
'seats' => (int) $lic['seats'],
'seats_used' => used_seats($pdo, (int) $lic['id']),
]);
/refresh and /activate share the same body on purpose: a site that has been running for a year should be able to renew without re-registering, and a revoked licence is simply a row with status = 'revoked'.
Idempotency on the payment side. The webhook that creates the licence should insert with order_ref as a unique column and treat a duplicate-key error as success. Payment providers retry webhooks, and a retry that mints a second key for the same order is a mess you will be cleaning up by hand.
The client side: a small plugin, not a per-request call
<?php
/**
* Plugin Name: Template Licence
*/
const TPL_LIC_SERVER = 'https://licences.example.com';
const TPL_LIC_OPTION = 'tpl_licence_token';
const TPL_LIC_PUBKEY = 'hex-public-key-from-sodium-crypto-sign-publickey';
function tpl_current_domain(): string
{
$host = wp_parse_url(home_url(), PHP_URL_HOST) ?: '';
return normalise_domain($host); // same function as the server
}
add_action('init', function () {
if (!wp_next_scheduled('tpl_licence_refresh') && get_option('tpl_licence_key')) {
wp_schedule_event(time() + 60, 'twicedaily', 'tpl_licence_refresh');
}
});
add_action('tpl_licence_refresh', function () {
$key = get_option('tpl_licence_key');
if (!$key) {
return;
}
$res = wp_remote_post(TPL_LIC_SERVER . '/refresh', [
'timeout' => 10,
'headers' => ['Content-Type' => 'application/json'],
'body' => wp_json_encode(['key' => $key, 'domain' => tpl_current_domain()]),
]);
if (is_wp_error($res)) {
return; // network trouble: keep the old token, do not brick the site
}
$body = json_decode(wp_remote_retrieve_body($res), true);
if (wp_remote_retrieve_response_code($res) === 200 && !empty($body['token'])) {
update_option(TPL_LIC_OPTION, $body['token'], false);
delete_option('tpl_licence_problem');
return;
}
update_option('tpl_licence_problem', sanitize_text_field($body['error'] ?? 'http_error'), false);
});
function tpl_b64url_decode(string $s): string
{
return base64_decode(strtr($s, '-_', '+/')) ?: '';
}
function tpl_licence_valid(): bool
{
$token = (string) get_option(TPL_LIC_OPTION);
if (substr_count($token, '.') !== 1) {
return false;
}
[$payloadB64, $sigB64] = explode('.', $token);
$payload = tpl_b64url_decode($payloadB64);
$sig = tpl_b64url_decode($sigB64);
if (!sodium_crypto_sign_verify_detached($sig, $payload, sodium_hex2bin(TPL_LIC_PUBKEY))) {
return false; // tampered or signed by someone else
}
$data = json_decode($payload, true);
return ($data['dom'] ?? '') === tpl_current_domain()
&& ($data['exp'] ?? 0) > time();
}
The public key ships with the plugin, so the client can verify but cannot mint. That is the point of using sodium_crypto_sign_* instead of a shared HMAC secret: a shared secret in distributed PHP code is not a secret.
Where the check should actually bite
Blocking the whole site on a bad licence punishes the customer's visitors, not the customer. In practice, gate these three things and leave the site running:
-
Update delivery. Your update endpoint requires
X-License-Key; no valid token, no new version. Attach it with a narrowhttp_request_argsfilter that only fires for your own update host. - Support. No token, no ticket. Say so in the email.
- Premium includes. Whatever ships separately from the template - extra page packs, the importer, the block library.
A front-end that renders a 200 with a small admin notice is enough pressure. A white screen generates chargebacks.
Failure modes to plan for now
- Server down. The plugin keeps the last token and retries. Give tokens a 14-day life and a refresh every 12 hours, so a two-day outage is invisible.
-
Domain migration. Add a self-service deactivation call (
DELETEthe activation row for a given key + domain, rate-limited) and tell customers about it on the same page as the key. -
Cloned staging.
staging.customer.comis a different domain and will hit the seat limit. Either treatstaging./.dev.subdomains of an active domain as covered, or give every licence two seats and describe it as "live + staging". -
Refunds. Flip
statustorefunded; the next refresh returns 403 and the token dies within 14 days. -
Rate limiting. Five activation attempts per IP per hour, stored in a
transient-like table. Keys are long, but the endpoint should not be a free oracle. -
Clock skew. Compare against
expwith a small grace window on the client, not on the server.
What this design does not do
PHP source is readable by the person running it. Anyone determined enough can delete the tpl_licence_valid() call and keep the files. That is true of every self-hosted PHP licensing scheme and it is worth being honest about internally, because it changes what you optimise for: make the licence the thing that delivers value - updates, support, the extra packs - rather than a lock that has to hold.
What it does do is stop the ordinary case: one purchase, six client sites, no record anywhere. That is the case that actually costs you money.
Build order
- Write the licence model sentence and put it on the product page.
- Add the two tables and the key generator.
- Wire the payment webhook to insert with
order_refunique, and email the plaintext key once. - Ship
/activateand/refresh. - Ship the client plugin with the public key baked in, and a
wp_schedule_eventrefresh. - Gate the update endpoint on the key header.
- Test: fresh activation, second activation on the same domain, sixth domain on a five-seat key, revoked key, server offline, staging clone.
Disclosure: I work at AumCreate, a one-person studio that sells WordPress templates and does the deployment for buyers. The template pages at aumcreate.com/templates/corporate are our own demo builds, not client projects - useful if you want to see how the per-domain wording reads on a real product page.
Top comments (1)
Solid design. One gap in the activation endpoint: the seat count check and the insert are separate steps, so two requests for different domains arriving together can both pass the check and push the licence over its limit. The UNIQUE constraint only protects the same-domain case. Wrapping the count and insert in a single write transaction (BEGIN IMMEDIATE in SQLite) closes it. Same idea for the webhook, which you already handle well with order_ref.