Licensing and updates for premium plugins

Deliver updates to a plugin that is not hosted on WordPress.org, check licences without hurting users, and understand what the GPL lets you sell.

  • Intermediate
  • 10 min read
  • Updated 17 Oct 2026

What you will learn

  • Serve plugin updates
  • Explain GPL obligations
  • Show plugin details in the update modal with plugins_api
  • Design licence checks that fail safely

Before you start

Plugins on WordPress.org get update notifications, one-click updates and automatic updates for free. A plugin you sell from your own site gets none of that by default: WordPress does not know where to look. This lesson shows the mechanics WordPress gives you to plug in your own update source, how licence checks fit around them, and what the GPL means for a commercial plugin.

How WordPress checks for updates

Twice a day, and when an administrator visits certain screens, wp_update_plugins() sends the list of installed plugins to the WordPress.org API. The answer is stored in the update_plugins site transient: an object whose response property lists plugins with updates, and whose no_update property lists plugins that are current. The Plugins screen, the updates screen, automatic updates and WP-CLI all read that transient.

So delivering your own updates comes down to three things:

  1. Stop WordPress.org from offering a different plugin's update for your slug.
  2. Add your plugin's update information to the transient.
  3. Answer the "View details" request with information about your release.

The Update URI header

If your plugin's folder name matches a plugin on WordPress.org, an update for that other plugin could overwrite yours. The Update URI header prevents that:

PHP
<?php
/**
 * Plugin Name:       My Plugin
 * Version:           1.4.0
 * Requires at least: 6.5
 * Requires PHP:      8.1
 * Text Domain:       myplugin
 * Update URI:        https://updates.example.com/myplugin
 * License:           GPL-2.0-or-later
 */

WordPress only applies WordPress.org updates to plugins whose Update URI is empty or points at WordPress.org. Any other value tells core that updates come from somewhere else. The hostname of the URI also names a filter, update_plugins_{$hostname}, here update_plugins_updates.example.com, which current versions of WordPress offer as a dedicated hook for supplying the update data. This lesson uses the older and more widely documented transient filter, which works the same way in principle.

Injecting the update

Filter the transient as it is saved with pre_set_site_transient_update_plugins. The update data comes from your server and is cached, so the remote request happens at most a few times a day:

PHP
<?php
// myplugin/src/Updates/Updater.php

namespace MyPlugin\Updates;

use MyPlugin\Contracts\Hookable;

defined( 'ABSPATH' ) || exit;

final class Updater implements Hookable {

    private const API_URL   = 'https://updates.example.com/v1/plugins/myplugin';
    private const CACHE_KEY = 'myplugin_update_info';
    private const SLUG      = 'myplugin';

    public function __construct( private string $plugin_file ) {}

    public function register(): void {
        add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'inject_update' ) );
        add_filter( 'plugins_api', array( $this, 'plugin_information' ), 10, 3 );
    }

    public function inject_update( $transient ) {
        if ( ! is_object( $transient ) ) {
            return $transient;
        }

        $remote = $this->remote_info();

        if ( null === $remote ) {
            return $transient;
        }

        $basename = plugin_basename( $this->plugin_file );
        $item     = (object) array(
            'id'           => 'https://updates.example.com/myplugin',
            'slug'         => self::SLUG,
            'plugin'       => $basename,
            'new_version'  => $remote['version'],
            'url'          => $remote['homepage'],
            'package'      => $remote['download_url'],
            'requires'     => $remote['requires'],
            'requires_php' => $remote['requires_php'],
            'tested'       => $remote['tested'],
        );

        if ( version_compare( MYPLUGIN_VERSION, $remote['version'], '<' ) ) {
            $transient->response[ $basename ] = $item;
        } else {
            $transient->no_update[ $basename ] = $item;
        }

        return $transient;
    }

    /**
     * Release information from the update server, cached for 12 hours.
     */
    private function remote_info(): ?array {
        $cached = get_site_transient( self::CACHE_KEY );

        if ( is_array( $cached ) ) {
            return empty( $cached['version'] ) ? null : $cached;
        }

        $response = wp_remote_post(
            self::API_URL,
            array(
                'timeout' => 10,
                'body'    => array(
                    'licence' => (string) get_site_option( 'myplugin_licence_key', '' ),
                    'site'    => home_url(),
                    'version' => MYPLUGIN_VERSION,
                ),
            )
        );

        $data = null;

        if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) {
            $decoded = json_decode( wp_remote_retrieve_body( $response ), true );

            if ( is_array( $decoded ) && isset( $decoded['version'], $decoded['download_url'] ) ) {
                $data = array(
                    'version'      => (string) $decoded['version'],
                    'download_url' => esc_url_raw( (string) $decoded['download_url'] ),
                    'homepage'     => esc_url_raw( (string) ( $decoded['homepage'] ?? '' ) ),
                    'requires'     => (string) ( $decoded['requires'] ?? '' ),
                    'requires_php' => (string) ( $decoded['requires_php'] ?? '' ),
                    'tested'       => (string) ( $decoded['tested'] ?? '' ),
                    'changelog'    => (string) ( $decoded['changelog'] ?? '' ),
                );
            }
        }

        // Cache failures briefly too, so a down server does not slow every admin page.
        set_site_transient(
            self::CACHE_KEY,
            $data ?? array( 'version' => null ),
            $data ? 12 * HOUR_IN_SECONDS : HOUR_IN_SECONDS
        );

        return $data;
    }
}

Register it from Plugin::register() with the main file path, for example new Updates\Updater( MYPLUGIN_PATH . 'myplugin.php' ). It must run on every request type that can check for updates, including cron and WP-CLI, not only in the admin.

The item fields match what WordPress.org returns:

FieldPurpose
slugFolder name, used by plugins_api and in links
pluginThe basename, myplugin/myplugin.php, the key in response
new_versionThe version on offer
packageThe ZIP URL WordPress downloads and installs
requires, requires_php, testedCompatibility, shown on the updates screen
urlYour plugin's home page

Listing the plugin in no_update when it is current lets the auto-updates column on the Plugins screen work for your plugin too.

The response is untrusted input, even from your own server. The code checks the status code, validates the decoded JSON and cleans URLs before storing them. Always use HTTPS and leave certificate verification on, which is the default for wp_remote_post().

The "View details" modal

The update notice links to "View details", which calls the plugins_api() function with the action plugin_information. By default it asks WordPress.org. Answer it yourself for your slug:

PHP
<?php
// myplugin/src/Updates/Updater.php (inside the class)

    public function plugin_information( $result, string $action, $args ) {
        if ( 'plugin_information' !== $action || self::SLUG !== ( $args->slug ?? '' ) ) {
            return $result;
        }

        $remote = $this->remote_info();

        if ( null === $remote ) {
            return $result;
        }

        return (object) array(
            'name'          => 'My Plugin',
            'slug'          => self::SLUG,
            'version'       => $remote['version'],
            'requires'      => $remote['requires'],
            'requires_php'  => $remote['requires_php'],
            'tested'        => $remote['tested'],
            'homepage'      => $remote['homepage'],
            'download_link' => $remote['download_url'],
            'sections'      => array(
                'changelog' => wp_kses_post( $remote['changelog'] ),
            ),
        );
    }

Returning $result unchanged for every other slug is essential, or you break the details modal for all other plugins. Sections are HTML displayed in the admin, so filter them with wp_kses_post().

Licences: what to gate

A licence key identifies a customer. The update server checks it before returning a download URL. Common rules:

  • Gate updates and support, not the code that is already installed. If a licence expires, the plugin keeps working; the customer stops receiving updates. Switching off features on an expired licence breaks customers' sites and generates support load out of proportion to the revenue it protects.
  • Make download URLs short-lived. The server returns a signed URL that expires after a few minutes, so a URL copied from one site cannot be shared for ever.
  • Count activations on the server. The update request includes home_url(), so the server can limit how many sites use a key and let customers move a licence between sites.
  • Never make the site depend on your server. If the licence server is down, the update check fails quietly, as in the code above. Never run licence checks on front-end page loads.
  • Store the key carefully. Save it with update_site_option(), so it is network-wide on Multisite, behind a settings form with a nonce and a capability check. Never print it in full in the admin.

Many teams use a licensing and update service for WordPress products, or a self-hosted update server, rather than writing the server side from scratch. Either way, the plugin side is what you have just seen.

Every update check sends data to your server: the site URL, plugin version and licence key. Say so in your documentation and in the privacy policy text from lesson 22. Send only what you need. Sending usage statistics or anything about the site's users needs explicit opt-in consent.

The GPL and selling plugins

WordPress is licensed under the GPL, version 2 or later. A plugin that runs inside WordPress, calls its functions and hooks into it is generally treated as a derivative work, and WordPress.org's position is that plugin PHP code must be GPL-compatible. In practice:

  • You may charge for your plugin. The GPL allows selling software. Customers pay for the download, updates, support and convenience.
  • Customers may share the code. Anyone who receives your plugin can modify and redistribute it under the GPL. You cannot forbid that with a licence agreement, an obfuscator or a licence check.
  • Licence keys are not copy protection. They control access to your services: your update server and your support desk. Someone redistributing the code does not get your updates.
  • Bundled libraries must be GPL-compatible too, as lesson 17 noted.
  • Include the licence. Declare it in the header, as above, and ship a licence file.

Your trademark is separate from the code licence. Others may redistribute the code, but not under your plugin's name and brand.

Free and premium together

A common model is a free plugin on WordPress.org and a premium add-on sold separately. The add-on declares Requires Plugins: myplugin from lesson 15, uses this lesson's updater, and extends the free plugin through its hooks. The free plugin must stay fully functional on its own: WordPress.org does not host trialware or plugins that lock features behind a payment, which the next lesson covers.

Summary

  • WordPress stores update information in the update_plugins site transient; add your plugin to it with pre_set_site_transient_update_plugins.
  • Set an Update URI header so WordPress.org updates never overwrite your plugin; current versions also offer update_plugins_{$hostname}.
  • Answer plugins_api for your slug only, and return the original result for every other plugin.
  • Cache remote checks, fail quietly, validate responses and use HTTPS.
  • Licences gate updates and support, not installed functionality; the GPL lets you sell the plugin but not stop customers sharing the code.

Further reading