WordPress & multilingual sites
WordPress Plugin Licensing System: How to Design a Licence Server
Selling a WordPress plugin means deciding how keys are activated, limited, revoked and renewed without punishing paying customers. Lessons from running our own licence server.
By AYM FlowPublished: Updated: 8 min read
Selling a premium WordPress plugin raises questions that free plugins never face. How does a customer activate a key? How many sites can use it? What happens when a licence expires or a payment is refunded? And how do you enforce all of it without locking honest customers out? This guide describes a WordPress plugin licensing system that works in production, drawing on running our own licence server for AYM Multilingual.
What a WordPress plugin licensing system should and should not do
A licence system exists to connect a purchase to entitlements: updates, support, premium features and a number of sites. It is not a perfect copy-protection tool. WordPress is licensed under the GPL, so determined people can always modify PHP code. Design the system to make the honest path easy and to protect your revenue from casual abuse, and never to damage the site of a paying customer.
Core architecture: key, activation, validation
The basic model has three parts: a licence record on your server, an activation call from the plugin, and a periodic validation.
- Issue keys at purchase, generated from a cryptographically secure random source and tied to a plan, an expiry date and a site limit. Store them server-side with hashed or otherwise protected lookups.
- Activate when the customer pastes the key. The plugin sends the key, the site domain and the plugin version over HTTPS. The server checks status, expiry and remaining slots, records the activation and returns a result.
- Validate periodically, for example once or twice a day through a scheduled event and a cached result, not on every page load. Each check confirms the licence is still valid for this domain.
- Gate premium behaviour on the stored result, so a brief network failure never changes how the site works.
Disclose exactly what is sent. Our own privacy policy states that the key, domain and plugin version are transmitted for validation, which is the minimum needed, and your customers deserve the same clarity in your documentation and privacy notice.
Keep the key format simple and human-friendly, because customers will copy it from an email, and make activation tolerant of stray spaces and line breaks. Store the activation result with a timestamp, the licence status and the expiry date it reported, so the plugin can decide locally what to do between checks. When activation fails, show a specific message such as 'key not found', 'no slots left' or 'licence expired', rather than a generic error, because vague failures create most licensing support tickets.
Example: a licence check that never breaks the site
The client side should be tolerant. This function asks the licence server at most every 12 hours, caches the verdict in a transient and treats a network error or a non-200 response as an outage rather than a verdict, so a server problem never locks a paying customer out.
<?php
defined( 'ABSPATH' ) || exit;
/**
* True while the licence is active. Asks the server at most every 12 hours;
* an outage keeps the last known verdict instead of locking the customer out.
*/
function acme_license_is_active(): bool {
$cached = get_transient( 'acme_license_status' );
if ( false !== $cached ) {
return 'active' === $cached;
}
$response = wp_remote_post( 'https://license.example.com/wp-json/acme-license/v1/check', array(
'timeout' => 8,
'body' => array(
'license_key' => get_option( 'acme_license_key', '' ),
'domain' => wp_parse_url( home_url(), PHP_URL_HOST ),
'version' => ACME_VERSION, // defined in the main plugin file
),
) );
// Network error or non-200 = outage, not a verdict: retry in an hour.
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
$last = get_option( 'acme_license_last_status', '' );
set_transient( 'acme_license_status', $last, HOUR_IN_SECONDS );
return 'active' === $last;
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
$status = isset( $data['status'] ) ? sanitize_key( $data['status'] ) : 'invalid';
update_option( 'acme_license_last_status', $status, false );
set_transient( 'acme_license_status', $status, 12 * HOUR_IN_SECONDS );
return 'active' === $status;
}The request fields (license_key, domain, version) and the rule that an outage returns a non-200 status mirror the contract of our own licence server. Define ACME_VERSION in the main plugin file, store the key with update_option when the customer activates, and gate premium features with acme_license_is_active().
Domain limits and moving sites
Plans differ by site count: a single-site licence, or a multi-site licence for agencies, as in our pricing for AYM Multilingual, where one tier covers one site and another covers up to five. Practical rules that avoid support pain:
- Normalise domains before comparing: lowercase, no protocol, and a decision on www and subdomains.
- Do not count local and staging environments against the limit. Detect common development hostnames or give customers a clear staging rule.
- Let customers deactivate a site themselves and free the slot, from the plugin and from an account page. Domain changes and migrations are normal, not abuse.
- Show remaining slots and the activated domains so customers can self-serve instead of emailing you.
Revocation, expiry and the grace period
Model licence states explicitly: active, expired, revoked and refunded. Each should map to a defined plugin behaviour.
- Expired means no more updates and support, not a broken site. Features that the customer relied on should keep working, or degrade gently with a clear notice.
- Revoked or refunded removes premium features or updates, but never deletes customer content or data.
- Grace period. If your server is unreachable, assume the last known good state for a defined window, such as several days, before showing any warning. Fail open on network errors, fail closed only on an explicit negative answer.
- Renewal flow. Remind customers before expiry and let a renewed key work without reinstalling anything.
The golden rule is that your outage must never become your customer's outage.
Delivering updates to licensed sites
Updates are the main entitlement. A plugin can hook into WordPress's update checks to ask your server about new versions, following the conventions in the plugin developer handbook. The server should verify the licence before returning a package URL, and the URL should be signed and short-lived so it cannot be shared. Return the changelog and compatibility data so the update screen looks like any other. Keep your update endpoints rate-limited and cached, since every licensed site will poll them.
Plan for versioned compatibility too. Return the minimum supported WordPress and PHP versions with every release, so the plugin never offers an update that would break a customer's site, and consider letting customers roll back to the previous version if a release causes problems. Updates are where licensing is most visible to customers, so reliability here earns more goodwill than any feature.
Example: offering updates only to licensed sites
WordPress asks for plugin updates through the update_plugins site transient. Hooking pre_set_site_transient_update_plugins lets a premium plugin add its own release to that list, but only while the licence is active.
<?php
// Must live in the main plugin file: plugin_basename( __FILE__ ) has to match the plugin path.
add_filter( 'pre_set_site_transient_update_plugins', function ( $transient ) {
if ( empty( $transient->checked ) || ! acme_license_is_active() ) {
return $transient;
}
$plugin = plugin_basename( __FILE__ );
$remote = get_transient( 'acme_update_info' );
if ( false === $remote ) {
// Your endpoint returns {"new_version": "...", "package": "...", "url": "..."}.
$response = wp_remote_post( 'https://license.example.com/wp-json/acme-license/v1/update', array(
'timeout' => 8,
'body' => array(
'license_key' => get_option( 'acme_license_key', '' ),
'domain' => wp_parse_url( home_url(), PHP_URL_HOST ),
),
) );
$remote = is_wp_error( $response ) ? array() : json_decode( wp_remote_retrieve_body( $response ), true );
$remote = is_array( $remote ) ? $remote : array();
set_transient( 'acme_update_info', $remote, 6 * HOUR_IN_SECONDS );
}
$installed = $transient->checked[ $plugin ] ?? '0';
if ( ! empty( $remote['new_version'] ) && ! empty( $remote['package'] )
&& version_compare( $remote['new_version'], $installed, '>' ) ) {
$transient->response[ $plugin ] = (object) array(
'slug' => dirname( $plugin ),
'plugin' => $plugin,
'new_version' => sanitize_text_field( $remote['new_version'] ),
'package' => esc_url_raw( $remote['package'] ), // short-lived, signed URL
'url' => esc_url_raw( $remote['url'] ?? '' ),
);
}
return $transient;
} );This code must live in the main plugin file, because plugin_basename(__FILE__) has to match the plugin's own path. The package URL should be a short-lived, signed download link generated by your server, so a leaked link stops working quickly.
Lessons from running our own licence server
Running licensing for AYM Multilingual taught us that most of the work is not cryptography but edge cases and support.
The same pattern is not limited to WordPress: our Multi Score Screen scoring software also issues a licence key per court by email, which the venue activates from its admin panel.
- Build admin tools first: search by key or domain, view activations, revoke, reassign and extend. Support requests are constant.
- Log every activation and check with the result, so you can answer 'why did my licence fail' within minutes.
- Handle cloned databases and changed domains gracefully, since site migrations happen all the time.
- Keep the plugin's licence code small, isolated and well tested, and never let a licence failure produce a PHP error.
- Use HTTPS everywhere and validate server responses, ideally signed, so the plugin cannot be tricked by a spoofed reply.
If you are planning a commercial plugin, start with the fundamentals in our WordPress plugin development guide. We design and build plugins, licence servers and update delivery through our WordPress plugin development service, and we can adapt what already runs in production for your product.
Frequently asked questions
How does a WordPress plugin licence key work?
The customer enters a key, the plugin sends it with the site domain to your licence server, and the server checks the plan, expiry and site limit. The result is stored and re-checked periodically.
Can WordPress licensing be cracked?
Code in a GPL-licensed plugin can always be modified, so licensing cannot be absolute protection. It protects updates, support and casual abuse. Value comes from updates, service and trust, not from lock-in.
What should happen when a licence expires?
The customer should lose updates and support but keep a working site. Premium features can degrade gracefully, and a renewal should restore everything without reinstalling.
Why use a grace period?
Servers go down and networks fail. A grace period prevents a temporary outage on your side from disabling features on a paying customer's site.
Related articles
WordPress & multilingual sites
Localization vs Translation: The Difference and How to Apply It to Your Website
Translation moves text into another language; localization rebuilds the site for someone living in that market. We explain the difference with examples from our own keyword research.
WordPress & multilingual sites
Add a Language to a WordPress Site: A Step-by-Step Guide
You want to add English or another language to your existing WordPress site. The “Site Language” setting is not enough; here is how to do it with the right structure, priorities and SEO steps.
WordPress & multilingual sites
Language Switcher Design: User-Friendly, Accessible and SEO-Safe
Flags or language names? Top-right corner or footer? The principles of a language switcher that visitors can find, use with a keyboard and that search engines understand.
