Add Licensing and Automatic Updates to Your Plugin or Theme
You sell your own plugin or theme from your WooCommerce store. Buyers expect what they get from WordPress.org: paste a key, click Activate, and see new versions in Dashboard > Updates with a one-click update. Without that, every release means emailing zip files and hoping people install them.
Stackless ships a drop-in license client for this. You copy one PHP file into your product, point it at your store, and it handles key activation, a daily license check, update notices, signed package downloads and the “View details” changelog.
Get the Client File
There’s no download button. The client is a file inside the Stackless plugin itself:
wp-content/plugins/stackless/templates/software_licensing/client/class-stackless-license-client.php
The License API and updates section under Stackless > Settings > Software Licensing says the same thing and shows your store’s API address, which looks like https://yourstore.com/wp-json/stackless/v1/licenses.
Copy the file into your product, for example my-plugin/includes/ or my-theme/inc/. It needs WordPress 5.8 or later and PHP 7.4 or later on the buyer’s site.
The same file handles plugins and themes. If the header of your copy only talks about plugins, it’s from an older Stackless. Update Stackless and copy the file again.
What the client does once it’s running:
- shows a license form you place on your own settings screen
- activates and deactivates the key for the buyer’s site
- re-checks the license once a day while an admin is in wp-admin
- adds your new version to WordPress’s plugin or theme update list, with a download link only for licensed sites
- answers the “View details” popup with your description and changelog (plugins)
Rename the Class First
The class is called STACKLESS_License_Client. Rename it in your copy, in both places it appears near the top of the file:
if (!class_exists('My_Plugin_License_Client')) {
class My_Plugin_License_Client
If two products on one site ship the class under the same name, the class_exists() guard stops a fatal error, but whichever copy loads first runs for both. There’s no prefix option, so renaming is the way to keep each product on its own copy.
You don’t need to rename anything else. The client already keeps its data per product by slug:
- the license is saved in the option
{slug}_stackless_license - the cached update answer is the site transient
{slug}_stackless_update - the form posts to the admin-post action
{slug}_stackless_license
The client’s own text (“License key”, “Activate”, “Deactivate” and so on) is English by default. To translate it, pass a strings array built with your own text domain, so your translation tools pick the strings up from your code:
'strings' => [
'license_key' => __('License key', 'my-plugin'),
'active' => __('Active', 'my-plugin'),
'activate' => __('Activate', 'my-plugin'),
'deactivate' => __('Deactivate', 'my-plugin'),
/* translators: %s: date */
'updates_until' => __('Updates until %s', 'my-plugin'),
/* translators: %s: product name */
'enter_valid_key' => __('Enter a valid license key for %s to enable automatic updates.', 'my-plugin'),
'bad_response' => __('The license server sent an unexpected response.', 'my-plugin'),
'not_allowed' => __('Sorry, you are not allowed to do that.', 'my-plugin'),
],
Any key you leave out keeps the English text.
Prepare the Product in Your Store
Open the product, go to Product data > Licensing, tick License keys and fill in the fields below. Site limit, validity and key format are covered in Sell Plugins and Themes with License Keys.
The update fields:
- Software slug: your plugin or theme folder name, such as
my-plugin. The client sends it with every request, and it must match theslugyou pass to the client. Stick to lowercase letters, numbers, dashes and underscores. The client strips anything else, including dots. - Current version: the newest version you’re shipping.
- Requires WordPress, Tested up to and Requires PHP: shown in the update details.
- Changelog: shown in the WordPress update details. Basic HTML is allowed.
- Update package: This product’s downloadable file, or A dedicated package URL.
- Downloadable file: which of the product’s downloadable files to use. The default is First downloadable file.
- Package URL: used with A dedicated package URL. A zip in your uploads folder is streamed through a signed link; a remote URL is redirected to.
The zip must contain the folder, so my-plugin.zip opens to my-plugin/my-plugin.php. For a theme, it opens to my-theme/style.css.
Add It to a Plugin
Settings the constructor reads:
store_url(required): your store’s address, such ashttps://yourstore.com.slug(required for plugins): the same value as Software slug.plugin_file(required for plugins): your main plugin file. Pass__FILE__from that file.theme(for themes, instead ofplugin_file): the theme folder name.slugthen defaults to it.version(required): the version of this copy. Keep it in sync with the plugin header.product_id(optional): the WooCommerce product ID. When set, the store refuses keys from other products. For a variable product, use the parent product’s ID.name(optional): shown in the update notice. Defaults to the slug.cache_ttl(optional): seconds to cache the store’s update answer. The default is 12 hours and the minimum is 1 hour.strings(optional): your own text for the license form and notices, covered below.
Here’s a complete main plugin file. It loads the renamed client, starts it on every request and adds a Settings > My Plugin screen with the license form:
<?php
/**
* Plugin Name: My Plugin
* Description: Sold and updated from my WooCommerce store.
* Version: 1.2.0
* Requires PHP: 7.4
* Text Domain: my-plugin
* Update URI: https://yourstore.com
*/
defined('ABSPATH') || exit;
define('MY_PLUGIN_VERSION', '1.2.0');
require_once __DIR__ . '/includes/class-my-plugin-license-client.php';
function my_plugin_license()
{
static $client = null;
if ($client === null) {
$client = new My_Plugin_License_Client([
'store_url' => 'https://yourstore.com',
'slug' => 'my-plugin',
'plugin_file' => __FILE__,
'version' => MY_PLUGIN_VERSION,
'product_id' => 123,
'name' => 'My Plugin',
]);
}
return $client;
}
my_plugin_license()->init();
add_action('admin_menu', function () {
add_options_page(
__('My Plugin', 'my-plugin'),
__('My Plugin', 'my-plugin'),
'manage_options',
'my-plugin',
'my_plugin_settings_page'
);
});
function my_plugin_settings_page()
{
echo '<div class="wrap">';
echo '<h1>' . esc_html__('My Plugin', 'my-plugin') . '</h1>';
echo '<h2>' . esc_html__('License', 'my-plugin') . '</h2>';
my_plugin_license()->render_form();
echo '</div>';
}
add_filter('plugin_action_links_' . plugin_basename(__FILE__), function ($links) {
$links[] = '<a href="' . esc_url(admin_url('options-general.php?page=my-plugin')) . '">' . esc_html__('License', 'my-plugin') . '</a>';
return $links;
});
The Update URI header is what the client’s own instructions recommend. It tells WordPress the plugin doesn’t update from WordPress.org.
Call init() when the file loads, not inside a later hook. WordPress checks for updates in the background, and the update filter has to be in place when that happens.
Add It to a Theme
For a theme, pass theme instead of plugin_file. The client then hooks WordPress’s theme update data, so your update shows in Appearance > Themes and Dashboard > Updates and installs with the normal Update now button.
Here’s the code for functions.php, with the renamed client saved as inc/class-my-theme-license-client.php and the class renamed to My_Theme_License_Client:
require_once get_template_directory() . '/inc/class-my-theme-license-client.php';
function my_theme_license()
{
static $client = null;
if ($client === null) {
$client = new My_Theme_License_Client([
'store_url' => 'https://yourstore.com',
'theme' => 'my-theme',
'version' => wp_get_theme('my-theme')->get('Version'),
'product_id' => 456,
'name' => 'My Theme',
]);
}
return $client;
}
my_theme_license()->init();
add_action('admin_menu', function () {
add_theme_page(
__('My Theme License', 'my-theme'),
__('Theme License', 'my-theme'),
'manage_options',
'my-theme-license',
function () {
echo '<div class="wrap">';
echo '<h1>' . esc_html__('My Theme License', 'my-theme') . '</h1>';
my_theme_license()->render_form();
echo '</div>';
}
);
});
The theme value has to be the theme’s folder name exactly, because WordPress files theme updates under that name. Set Software slug on the product to the same name. Reading version from wp_get_theme() keeps it in sync with style.css.
For a child theme you sell, use the child theme’s folder name and load the client with get_stylesheet_directory() instead of get_template_directory().
Theme updates don’t have the plugin “View details” popup. WordPress links the version number to the product page your store sends, and if the key isn’t licensed it shows that automatic update is unavailable for the theme.
What Your Buyer Sees in wp-admin
render_form() prints the license form wherever you call it, and only for users who can manage_options. The client doesn’t add a menu item of its own.
- Before activation: a License key field and an Activate button. If the store refuses the key, its message shows in red under the field.
- After activation: the key masked to its first 6 and last 4 characters, a green Active label, “Updates until” with the expiry date when the key has one, and a Deactivate button.
The form posts to admin-post.php with a nonce and a capability check, then returns to your screen. If you’d rather build your own UI, call the public methods directly:
activate($key)anddeactivate()return the store’s decoded answer. Check['success'].check()asks the store for the current status and saves it.get_license()returns the saved key, status, expiry, last check time and last message.is_active()is true when a key is saved and its status is active.
How Update Checks Work
- Whenever WordPress saves its plugin update data (or theme update data for a theme), the client’s
pre_set_site_transient_update_pluginsorpre_set_site_transient_update_themesfilter runs. - If no key is saved, the client asks nothing and shows no update.
- Otherwise it sends a POST request to
{store_url}/wp-json/stackless/v1/licenses/versionwith the license key, the site’s home URL, the slug, the installed version and the product ID when set. - The store answers with the current version, the requirements, the changelog and, for a licensed site, a package link.
- If the store’s version is higher than the installed one, WordPress lists the update.
The answer is cached in the {slug}_stackless_update site transient for 12 hours by default. A failed request is cached for 1 hour, so a slow store never drags down the buyer’s admin. Activating, deactivating and finishing an update all clear the cache.
Separately, once a day when an admin loads wp-admin, the client posts to the check endpoint and saves the new status. It records the time first, so an unreachable store is tried again the next day rather than on every page load.
How the Package Download Is Authorized
The store only includes a package link when the license is active and this site is one of its activated sites. That link points to your store’s download endpoint and carries the license ID, the product ID, an expiry time and a signature. It never contains the key.
- The link stops working after Download link lifetime (hours), 24 by default.
- At download time the store checks the signature and expiry again, and checks that the license is still active.
- A zip on your server is streamed. It has to be inside
wp-contentor the WordPress folder, and PHP files are never served. A remote package URL is redirected to instead.
If the site has a key but isn’t licensed for updates, WordPress still shows the new version with no download. Next to it the client adds “Enter a valid license key for My Plugin to enable automatic updates.”
View Details and the Changelog
Clicking “View details” on your plugin calls the client’s plugins_api filter, which asks the store’s info endpoint for your slug. The popup shows your plugin name, version, the product description, the Changelog field from the product, the requirements and your product image as the icon and banner. The author links to your store and the homepage is the product page. With a valid key on an activated site the popup also gets a download link.
Deactivate and Move to Another Site
Clicking Deactivate tells the store to free the activation and then deletes the key from the site. The key is forgotten locally even if the store can’t be reached. To move a license:
- Deactivate on the old site, or free the site from My Account > Licenses (when Customer site removal is on), or from the license’s Activated sites table in Stackless > Licenses.
- Enter the key on the new site and click Activate.
The store identifies a site by its home URL, ignoring http or https, a leading www. and a trailing slash. Subfolder installs count as separate sites. Activating a site that’s already active succeeds without using another slot.
Development and Staging Sites
With Development sites on (the default), activations on local and staging addresses don’t count toward the site limit. They still need a valid key and still show in the site list, tagged as development.
These count as development sites:
localhost, single-word hosts and private or reserved IP addresses- hosts ending in
.test,.local,.localhost,.invalid,.example,.internal,.lan,wp.local,ddev.siteorlndo.site - hosting staging domains:
wpengine.com,wpenginepowered.com,kinsta.cloud,pantheonsite.io,flywheelstaging.com,flywheelsites.com,cloudwaysapps.com,instawp.xyz,instawp.coandtastewp.com - subdomains starting with
staging,stage,stg,dev,development,test,testing,qa,uat,sandbox,localorpreview, such asstaging.client.com
Add your own under More development hosts, one per line, like *.mystaging.host or preview.example.com. A plain domain also matches its subdomains.
When a License Expires or Is Revoked
- Activation is refused with the store’s message, such as “This license has expired. Renew it to keep receiving updates and support.”
- The daily check saves the new status, and the buyer’s form goes back to the empty key field with that message.
- WordPress keeps listing new versions, but without a download.
- Package links already issued stop working, because the store checks the license again at download time.
The client never switches your product off. If you want features to stop with the license, check is_active() in your own code. When the customer renews, the next daily check finds the key active again, and the site stays on the list throughout.
Publish a New Version
- Change the version in your plugin header (or
style.css) and in theversionyou pass to the client. - Build the zip with your product folder inside it.
- Replace the product’s downloadable file, or upload the zip and point Package URL at it. For a variable product the package comes from the purchased variation’s downloadable file, so update each variation, or use a package URL.
- On Product data > Licensing, set Current version to the new number, add the release to Changelog and update Tested up to if needed.
- Click Update.
Buyers see the update the next time WordPress refreshes its update data after their cached answer runs out, so allow up to 12 hours with the default cache.
Good to Know
- Keep Download link lifetime (hours) at 12 or more. The client caches the package link along with the version, so a shorter lifetime can leave buyers with an expired link until their cache refreshes.
- Every request the client makes is a POST, so license keys never appear in URLs or in your store’s access logs. Copies of the client from older Stackless releases sent the update check as a GET with the key in the URL; copy the current file to fix that.
- Requests per minute (default 30) applies per IP address and per key. After Failed lookups before a block (default 10) unknown keys from one IP address within 15 minutes, that IP is refused for the rest of the window. Buyers on shared hosting share an IP address.
- Every client copy on a site shares one
stackless_license_instance_idoption. It’s sent on activation and shown under the site in Stackless > Licenses, which helps you recognize a reinstalled copy.