How to License a WordPress Plugin or Theme: Sites, Staging and Updates
Add license keys to a WordPress plugin: per-site activation, staging exceptions, WP-Cron checks, safe failure and updates only for valid keys.
A WordPress plugin license key needs a policy for sites, staging copies and updates. A customer can clone a database, move from HTTP to HTTPS or restore a backup without buying a new product. Decide which changes represent a new licensed installation before writing the activation call. Keep published pages available while the licensing check runs.
This guide uses PermitCore PHP SDK 2.1.1 and a single-site plugin example. It also explains the decisions a theme developer needs to make. The code is an integration example you package and test yourself; it is not an official WordPress plugin or updater library. Start with issuing your first license if you do not yet have a product and a test key.
Why the site becomes the device identifier
PermitCore's existing activation endpoint accepts a deviceId. Here, the plugin chooses that value from the site's normalized hostname. This does not introduce a domain activation type, prove domain ownership or discover which WordPress installation is genuine. A site administrator controls the PHP code and the WordPress configuration.
For this production example, configure a node-locked test license with one activation slot. A known device can reactivate without consuming another seat; a different device cannot take an occupied single seat. These are tested activation behaviors. The license sharing guide discusses what client checks can enforce and where your own server needs to make the final decision.
Normalize the hostname from home_url()
Use the site's configured home_url(), which describes its public address. The request's Host header is an unsuitable licensing identity. Parse the hostname, lowercase it, remove a terminal dot and deliberately collapse a leading www.. Ignore scheme, port, path and trailing slash. Under this policy, http://www.example.com/ and https://example.com:8443/shop/ share one identifier.
That equivalence is a product decision. Different applications under the same hostname also share a seat here. If your offer licenses subdirectories separately, design and test a different identifier before launch. Use ASCII hostnames, including an explicitly configured punycode hostname where needed; this example does not implement internationalized-domain conversion. WordPress documents the underlying home_url function.
<?php
declare(strict_types=1);
function acme_site_host(): string
{
$host = strtolower(rtrim((string) wp_parse_url(home_url('/'), PHP_URL_HOST), '.'));
return str_starts_with($host, 'www.') ? substr($host, 4) : $host;
}
function acme_nonproduction(string $host): bool
{
return wp_get_environment_type() !== 'production'
|| in_array($host, ['localhost', '127.0.0.1', '[::1]'], true)
|| str_ends_with($host, '.localhost')
|| str_ends_with($host, '.local')
|| str_ends_with($host, '.test')
|| str_starts_with($host, 'staging.');
}
function acme_license_scope(string $key, string $host): string
{
return hash('sha256', $key . "\0" . $host . "\0"
. ACME_PRODUCT_ID . "\0" . wp_get_environment_type());
}
function acme_entitled(): bool
{
$key = (string) get_option('acme_license_key', '');
$host = acme_site_host();
$good = get_option('acme_license_good', []);
return !is_multisite() && $key !== '' && $host !== ''
&& is_array($good)
&& ($good['scope'] ?? '') === acme_license_scope($key, $host)
&& (int) ($good['until'] ?? 0) > time();
}
Use the real PHP activation and validation calls
Install permitcore/sdk with Composer in your build workspace, then package its vendor directory with the example files. Require PHP 8.0 or later and the SDK's cURL, JSON and OpenSSL extensions. Replace the sample product GUID with the public product ID from your account. The API base URL and product ID are configuration, while management credentials belong on your controlled backend.
composer require permitcore/sdk:2.1.1The refresh function below sends wp-host: plus the normalized hostname to activate() on production. Repeating that call on a node-locked license uses the same device identity. On a recognized development or staging installation, it calls validate(), which checks the key and expected product without allocating an activation. The SDK handles the activation nonce.
Always pass expectedProductId. A key for another product must not unlock this plugin. Read isValid and the machine-readable errorCode, including WrongProduct; an HTTP 200 response can still contain a rejected license. The PHP SDK reference and API error reference describe those contracts.
<?php
declare(strict_types=1);
use PermitCore\PermitCoreClient;
function acme_refresh_license(?PermitCoreClient $client = null): void
{
$key = (string) get_option('acme_license_key', '');
$host = acme_site_host();
if (is_multisite() || $key === '' || $host === '') {
delete_option('acme_license_good');
return;
}
$client ??= new PermitCoreClient(
'https://api.permitcore.dev', enableOfflineCache: false,
timeout: 3, deviceId: 'wp-host:' . $host, maxRetries: 0
);
$result = acme_nonproduction($host)
? $client->validate($key, expectedProductId: ACME_PRODUCT_ID)
: $client->activate(
$key, deviceId: 'wp-host:' . $host, deviceName: $host,
expectedProductId: ACME_PRODUCT_ID
);
if ($result->isValid) {
$until = time() + DAY_IN_SECONDS;
if ($result->expiresAt !== null) {
$until = min($until, strtotime($result->expiresAt) ?: 0);
}
update_option('acme_license_good', [
'scope' => acme_license_scope($key, $host), 'until' => $until,
], false);
set_transient('acme_license_notice', 'License checked.', HOUR_IN_SECONDS);
} elseif ($result->errorCode === 'RequestFailed') {
// Retain the previous deadline. An outage never extends it.
set_transient('acme_license_notice', 'Check delayed; retry later.', HOUR_IN_SECONDS);
} else {
delete_option('acme_license_good');
set_transient('acme_license_notice', 'License needs attention.', HOUR_IN_SECONDS);
}
}
Staging, localhost and multisite need explicit rules
The hostname helper recognizes localhost, loopback addresses, *.localhost, *.local, *.test and a staging. prefix. It also respects any wp_get_environment_type() value other than production. A name such as staging-shop.example.com does not match the prefix rule. Set the environment explicitly when a hosting provider uses another convention.
Development detection changes this example's choice of SDK call. It does not create a PermitCore-enforced staging allowance or grant access without a valid key. An operator can change the environment flag, so it cannot independently protect a download service against abuse. WordPress describes the supported values and production default in its environment type reference.
Multisite needs another commercial decision. Subdirectory sites can share a hostname; mapped domains can differ. A network-wide purchase and a purchase for each blog need different identifiers, storage and administration permissions. This example checks is_multisite() and declines to authorize premium operations there. It leaves the front end running and displays an admin notice. Implement and test the network policy separately.
Schedule checks and keep settings behind administrator permissions
Place all three PHP files in your plugin directory. The bootstrap below schedules one hourly event when the plugin activates and removes it on deactivation. Saving a key schedules a check rather than waiting for an API call inside the settings form. The frontend does not make a blocking license request on every page load.
WP-Cron runs due work when WordPress gets traffic; hourly scheduling is not a promise that a request executes at an exact minute. Use a properly configured system cron trigger if your deployment needs predictable checking. An expired local deadline stays expired even when the scheduled event is late. See WordPress's recurring event reference.
The settings page and save handler both require manage_options. The handler verifies a nonce before changing the stored key. Output is escaped, the existing key is never printed back into the form, and changing the key clears the previous entitlement. Store the customer key with autoload disabled, restrict access to database backups and avoid logging request bodies. This example does not encrypt WordPress options.
<?php
/**
* Plugin Name: Acme licensing example
* Requires PHP: 8.0
*/
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/site-policy.php';
require_once __DIR__ . '/refresh.php';
// Replace with your product's public GUID before packaging.
const ACME_PRODUCT_ID = '11111111-2222-3333-4444-555555555555';
register_activation_hook(__FILE__, function (): void {
if (!is_multisite() && !wp_next_scheduled('acme_license_check')) {
wp_schedule_event(time(), 'hourly', 'acme_license_check');
}
});
register_deactivation_hook(__FILE__, function (): void {
wp_clear_scheduled_hook('acme_license_check');
});
add_action('acme_license_check', 'acme_refresh_license');
add_action('admin_menu', function (): void {
add_options_page('Acme license', 'Acme license', 'manage_options',
'acme-license', 'acme_license_page');
});
function acme_license_page(): void
{
if (!current_user_can('manage_options')) {
wp_die(esc_html('Permission denied.'), '', ['response' => 403]);
}
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
wp_nonce_field('acme_save_license');
echo '<input type="hidden" name="action" value="acme_save_license">';
echo '<label>License key <input name="license_key" type="password" value="" required autocomplete="off"></label>';
echo '<button type="submit">Save and schedule check</button></form>';
echo '<p>' . esc_html((string) get_transient('acme_license_notice')) . '</p>';
if (is_multisite()) {
echo '<p>' . esc_html('This example requires a separate multisite policy.') . '</p>';
}
}
add_action('admin_post_acme_save_license', function (): void {
if (!current_user_can('manage_options')) {
wp_die(esc_html('Permission denied.'), '', ['response' => 403]);
}
check_admin_referer('acme_save_license');
$key = trim(wp_unslash((string) ($_POST['license_key'] ?? '')));
if ($key === '') {
wp_die(esc_html('Enter a license key.'));
}
update_option('acme_license_key', $key, false);
delete_option('acme_license_good');
if (!is_multisite()) {
wp_schedule_single_event(time(), 'acme_license_check');
}
wp_safe_redirect(admin_url('options-general.php?page=acme-license'));
exit;
});
add_filter('pre_set_site_transient_update_plugins', function ($updates) {
if (is_object($updates) && !acme_entitled()) {
unset($updates->response[plugin_basename(__FILE__)]);
}
return $updates;
});
// Call acme_entitled() before a paid admin operation.
// Keep ordinary frontend rendering independent of this decision.
Handle an unreachable API without taking the site down
The example disables the SDK's offline cache and implements a local, bounded convenience policy for the plugin's paid admin operations. A successful check records the current key/site/product/environment scope and a deadline of at most 24 hours, capped by the returned license expiry. A timeout reported as RequestFailed preserves that deadline. It never creates a first success or moves the deadline forward.
This WordPress option is editable by the site operator. It is not signed PermitCore offline proof and does not authorize server-hosted downloads. PermitCore's PHP signed cache has separate trusted-public-key and context requirements, described in the offline grace guide. Choose and test one policy deliberately instead of treating an arbitrary saved “valid” flag as cryptographic verification.
Every other negative result clears this sample's entitlement. That includes wrong-product, expired or missing-key decisions, HTTP rejections and malformed responses. Show a quiet “check delayed” message for a connection failure and an actionable licensing message for a rejection. Neither condition calls a frontend shutdown function. Keep existing content rendering independent of acme_entitled().
Put the entitlement check at the boundary of each new paid operation, such as an administrator starting a premium export. Keep previously saved posts, configuration and ordinary rendering accessible when the deadline passes. An update notice is feedback; your download endpoint is a separate authorization boundary. If a premium component contributes frontend output, define how existing content keeps rendering before adding the licensing condition. Test that behavior with the API disconnected as well as with a deliberately rejected key, using a disposable copy of a representative customer site.
Gate update offers and authorize the ZIP separately
The pre_set_site_transient_update_plugins filter removes only this plugin's update entry when its cached entitlement is unavailable. It does not generate release metadata or a package URL. Your distribution service must already provide those. A theme needs its corresponding update hook and a separately tested implementation.
Protect the ZIP download on your server. Recheck the product and entitlement there before issuing short-lived download authorization. A public ZIP URL remains public even when WordPress hides its update notice. Keep management API keys out of the installed plugin, and test that a direct unauthenticated download is denied. The guide to building a licensing system explains the client/server boundary.
When a revocation or refunded purchase leads to a negative license decision, the refresh clears the local entitlement. That becomes visible at the next successful check, subject to this example's bounded outage window. Your commerce integration owns the refund-to-license decision; the updater filter does not process payments. See license handling after a failed payment and license error messages.
Move a license without inventing an activation ID
The PHP SDK's activate() result does not expose an activationId. Do not fabricate one from the hostname or key. This example directs customers to their existing account's device list, where they select and confirm removal of the old activation, then save the key on the new production site. Account ownership and any configured removal policy still apply.
The SDK has deactivate(tenantSlug, licenseKey, activationId), but calling it requires the actual selected activation ID and a permitted removal flow. It does not supply a complete WordPress transfer UI. The license transfer guide covers the existing lifecycle. A restored backup with a different hostname cannot reuse this example's old local success record.
Before selling a premium plugin or theme
- Write down the licensed unit, www equivalence, staging policy and multisite decision.
- Exercise production activation, repeated activation and a second hostname against a single-seat test license.
- Test wrong-product keys, revocation, timeouts, expired local state and copied WordPress databases.
- Verify settings permissions, nonce rejection, escaped notices and the absence of plaintext keys in logs.
- Authorize downloads on your server and keep ordinary site rendering available through all failure paths.
- Run the supplied hostname and policy tests, then test your packaged plugin on the WordPress/PHP versions you support.
The accompanying tests exercise the exact example files with WordPress function stubs and the real PHP SDK's method signatures. They do not establish a hosted WordPress integration. Treat this as a starting implementation whose packaging, update service and supported environments you still verify.
Build your first licensed product without building a license server.
Create a free PermitCore account, issue a test license, and validate it from your application.