Skip to content
Gaurav TiwariDocs

Add Licensing and Automatic Updates to Your Plugin or Theme

Version 1.0.0-beta.1 Updated October 28, 2026

You sell your own plugin or theme from your Easy Digital Downloads 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 for EDD 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” window.

Get the Client File

There’s no download button. The client is a file inside the Stackless for EDD plugin folder:

templates/software_licensing/client/class-stackless-edd-license-client.php

The License API and updates section under Stackless → Settings → Software Licensing points to the same folder and shows your store’s API address, which looks like https://yourstore.com/wp-json/stackless-edd/v1/licenses.

Copy the file into your product, for example my-plugin/includes/ or my-theme/inc/. Its header asks for WordPress 5.8 or later and PHP 7.4 or later on the buyer’s site.

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” window with your description and changelog (plugins)

Rename the Class First

The class is called STACKLESS_EDD_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. Renaming keeps 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_edd_license
  • the cached update answer is the site transient {slug}_stackless_edd_update
  • the form posts to the admin-post action {slug}_stackless_edd_license

Prepare the Download in Your Store

Edit the download, tick License keys in its Licensing box and fill in the update fields. Site limit, validity and key format are covered in Sell Plugins and Themes with License Keys.

The Software group:

  • Software slug: your plugin or theme folder name, such as my-plugin. The client sends it with every request, and it must match the slug you 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 allowed.

The Update package group:

  • Update package: This download’s file (default) or A dedicated package URL.
  • Download file: which of the download’s files to use. The default is First file. Price options with files of their own use their first file when this one isn’t theirs.
  • 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.

The WordPress updater listing group sets how the update looks on buyers’ sites:

  • Software type: Plugin or Theme. It shows the fields that apply.
  • Plugin icon: a square 256 × 256 px PNG or JPG, or an SVG, shown in Dashboard → Updates and on the Plugins screen. Without one, the download image is used.
  • Details banner: 1544 × 500 px (or 772 × 250) across the top of “View details”. Without one, the download image is used.
  • Theme screenshot: 1200 × 900 px, shown in the theme’s update details.
  • Upgrade notice: one short line under the update on the Plugins screen.
  • Author, Author URL and Homepage: default to your site name, your home page and the download’s page.
  • Requires plugins: slugs of plugins yours needs, separated by commas.
  • Description tab, Installation tab and FAQ tab: the tabs of “View details”. An empty Description tab uses the download description.

Add It to a Plugin

Settings the constructor reads:

  • store_url (required): your store’s address, such as https://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 of plugin_file): the theme folder name. slug then defaults to it.
  • version (required): the version of this copy. Keep it in sync with the plugin header.
  • download_id (optional): the EDD download ID. When set, the store refuses keys for other software. product_id is read as an alias.
  • name (optional): shown in the update notice. Defaults to the slug.
  • cache_ttl (optional): seconds to cache the store’s update answer. Default 12 hours, minimum 1 hour.
  • strings (optional): your own text for the license form and notices.

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 Easy Digital Downloads 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,
            'download_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>';
}

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 Dashboard → Updates and Appearance → Themes like any other theme. Here’s the code for functions.php, with the renamed client saved as inc/class-my-theme-license-client.php:

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'),
            'download_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 download to the same name, and Software type to Theme. 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(). The client only answers “View details” for plugins.

Translate the Client’s Text

The client’s own text 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'),
    'package_expired'     => __('The download link expired and the store did not send a new one. Check the license, then try the update again.', 'my-plugin'),
    'details_unavailable' => __('The plugin details could not be loaded from the store. Try again later.', 'my-plugin'),
],

Any key you leave out keeps the English text.

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) and deactivate() 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, expires_at, checked time and last message.
  • is_active() is true when a key is saved and its status is active.

How Update Checks Work

  1. Whenever WordPress saves its plugin update data (or theme update data for a theme), the client’s pre_set_site_transient_update_plugins or pre_set_site_transient_update_themes filter runs.
  2. If no key is saved, the client asks nothing and shows no update.
  3. Otherwise it sends a POST request to {store_url}/wp-json/stackless-edd/v1/licenses/version with the license key, the site’s home URL, the slug, the installed version and the download ID when set.
  4. The store answers with the current version, the requirements, the changelog and, for a licensed site, a package link.
  5. If the store’s version is higher than the installed one, WordPress lists the update.

The answer is cached 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 clear the cache.

If the store’s /wp-json/ address returns a web page instead of JSON (plain permalinks, or a changed REST prefix), the client retries through ?rest_route= and remembers what worked.

Separately, once a day when an admin loads wp-admin, the client posts to the check route 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. The link points to your store’s download route and carries the license ID, the download ID, an expiry time and a signature. It never contains the key.

  • The link stops working after Download link lifetime (hours), 24 by default.
  • WordPress can install from an update check made hours earlier. Just before it downloads, the client swaps a link that has expired, or expires within 5 minutes, for a fresh one.
  • At download time the store checks the signature and expiry again, and checks that the license may still download.
  • A zip on your server is streamed. It has to be inside wp-content or 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 route for your slug. The window shows your plugin name, version, the Description, Installation, FAQ and Changelog tabs, the requirements and your icon and banner. With a valid key on an activated site it also gets a download link.

If the store refuses the saved key, the client asks again without it. If the store still can’t answer, the window shows an error instead of falling back to WordPress.org, which might list a different plugin under your slug.

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:

  1. Deactivate on the old site, or free the site from the account page’s Licenses tab (when Customer site removal is on), or from the license’s Activated sites table in Stackless → Licenses.
  2. Enter the key on the new site and click Activate.
Licenses tab on the Stackless for EDD account page with two licenses, each listing its activated sites with Deactivate buttons and a localhost or .test site tagged Development, not counted

The store identifies a site by its home URL, ignoring http or https, a leading www., a default port 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 .localhost, .test, .local, .invalid, .example, .internal, .lan, wp.local, ddev.site or lndo.site
  • hosting staging domains: wpengine.com, wpenginepowered.com, kinsta.cloud, pantheonsite.io, flywheelstaging.com, flywheelsites.com, cloudwaysapps.com, instawp.xyz, instawp.co and tastewp.com
  • subdomains starting with staging, stage, stg, dev, development, test, testing, qa, uat, sandbox, local or preview, such as staging.client.com

Add your own under More development hosts, one per line, like *.mystaging.host or preview.example.com.

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.

Publish a New Version

  1. Change the version in your plugin header (or style.css) and in the version you pass to the client.
  2. Build the zip with your product folder inside it.
  3. Replace the download’s file, or upload the zip and point Package URL at it.
  4. In the Licensing box, set Current version to the new number, add the release to Changelog and update Tested up to if needed.
  5. Click Update.

Saving a new Current version dates it in Release history. To keep the old zip available for rollbacks, add the new file instead of replacing it and pick it under Download file. Then name or tag the old file with its version.

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

  • Every request the client makes is a POST, so license keys never appear in URLs or in your store’s access logs.
  • 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_edd_license_instance_id option. It’s sent on activation and shown under the site in Stackless → Licenses, which helps you recognize a reinstalled copy.
  • To talk to the store without the client, see the License API Reference.
Share

Was this helpful?

Still stuck? Open a support ticket