DEV Community

Cover image for Demystifying WordPress Plugin License Activation: A Step-by-Step Client-Server Guide
Shahibur Rahman
Shahibur Rahman

Posted on

Demystifying WordPress Plugin License Activation: A Step-by-Step Client-Server Guide

Understanding WordPress Plugin License Activation can often feel opaque for developers and users. How do commercial plugins truly verify legitimate usage? How are domain limits gracefully enforced without being overly restrictive or easily bypassed? This article aims to pull back the curtain, breaking down a robust, complementary client-server process for managing plugin licenses effectively.

We'll explore how modern payment and license key generation platforms (like Lemon Squeezy or Easy Digital Downloads) integrate with custom license hubs. This intricate dance involves webhooks, secure database management, and API validation, ensuring your WordPress Plugin License Activation system is both secure and scalable.

The Core of WordPress Plugin License Activation: Client-Server Harmony

At its core, a robust WordPress Plugin License Activation system involves two main components working in harmony:

  1. Server-Side (License Management Hub): This is typically a dedicated WordPress installation or a separate web application. It acts as the central authority, listening for purchase events (via webhooks), securely storing license data, and providing an API for client plugins to validate their licenses.
  2. Client-Side (Your Premium WordPress Plugin): This is your plugin, installed on a user's site. It communicates with the License Management Hub to activate, validate, and periodically check its license status.

Let's dive into the technical details of how these two sides interact.

Part 1: The Server-Side Foundation - Webhooks & Database Management

Our journey begins the moment a customer purchases your plugin. Upon a successful payment, the payment platform dispatches a license_key_created webhook to a specified endpoint on your License Management Hub. This webhook carries crucial information about the newly generated license key.

Securely Receiving Webhooks with LicenseHubWebhookReceiver

The LicenseHubWebhookReceiver class is designed to set up a REST API endpoint within your WordPress License Management Hub. This endpoint securely catches incoming webhooks and processes them.

<?php
defined( 'ABSPATH' ) || exit;

class LicenseHubWebhookReceiver {
    private $plugin_base_slug;

    public function __construct($plugin_base_slug) {
        $this->plugin_base_slug = $plugin_base_slug;
    }

    public function register_webhook_route() {
        register_rest_route('my-licensing-api/v1', '/payment-webhook', [
            'methods'  => 'POST',
            'callback' => [$this, 'handle_incoming_webhook'],
            'permission_callback' => '__return_true', // Signature verification inside callback
        ]);
    }

    public function handle_incoming_webhook(WP_REST_Request $request) {
        $body      = $request->get_body();
        $signature = $request->get_header('x-signature'); // Example header for webhook signature
        $secret    = get_option($this->plugin_base_slug . '_webhook_secret'); // Stored securely

        // 1. SECURITY: Verify the Webhook Signature
        if (!$this->is_valid_webhook_signature($body, $signature, $secret)) {
            error_log("Invalid Webhook Signature detected.");
            return new WP_REST_Response(['message' => 'Invalid signature'], 401);
        }

        $data = json_decode($body, true);
        if (empty($data)) {
            return new WP_REST_Response(['status' => 'empty_payload'], 400);
        }

        $event = $data['meta']['event_name'] ?? ''; // e.g., 'license_key_created'
        $db    = new LicenseHubDataManager($this->plugin_base_slug);

        if ($event === 'license_key_created') {
            $attributes = $data['data']['attributes']; // Contains license key, email, limits, etc.
            $inserted   = $db->insert_new_license($attributes);

            if ($inserted) {
                error_log("New license key inserted: " . $attributes['key']);
            }
        }

        return new WP_REST_Response(['status' => 'success'], 200);
    }

    /**
     * Verifies the webhook signature against a shared secret.
     */
    private function is_valid_webhook_signature($payload, $signature, $secret) {
        if (empty($signature) || empty($secret)) {
            return false;
        }
        $computed_signature = hash_hmac('sha256', $payload, $secret);
        return hash_equals($computed_signature, $signature);
    }
}
Enter fullscreen mode Exit fullscreen mode

Key takeaway here: The is_valid_webhook_signature method is absolutely critical for security. It ensures that the incoming webhook genuinely originated from your payment platform and hasn't been tampered with, preventing malicious actors from forging license keys or triggering false events.

Storing License Data with LicenseHubDataManager

Once the webhook is verified, the handle_incoming_webhook method delegates to insert_new_license from LicenseHubDataManager. This class is responsible for all database interactions, including creating the necessary tables and storing the license details.

Here's a simplified look at how a plugin_licenses table might be structured:

<?php
// ... (part of LicenseHubDataManager::create_license_table method)

        $sql_licenses = "CREATE TABLE $table_licenses (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            license_key varchar(100) NOT NULL,
            product_item_name varchar(255) NOT NULL,
            license_tier varchar(50) DEFAULT 'standard' NOT NULL,
            allowed_activations int(11) DEFAULT 1 NOT NULL,
            customer_email varchar(105) NOT NULL,
            activated_urls text DEFAULT NULL,
            status varchar(20) DEFAULT 'active' NOT NULL,
            created_at datetime NOT NULL,
            PRIMARY KEY  (id),
            UNIQUE KEY license_key (license_key),
            KEY product_item_name (product_item_name)
        ) $charset_collate;";

        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta($sql_licenses);
// ...
Enter fullscreen mode Exit fullscreen mode

And the insert_new_license method that populates this table:

<?php
// ... (part of LicenseHubDataManager class)

    /**
     * Stores new license data from webhook payload.
     */
    public function insert_new_license($license_data) {
        $license_key = sanitize_text_field($license_data['key']);

        // 1. Prevent Duplicates: Check if license key already exists
        $exists = $this->wpdb->get_var($this->wpdb->prepare(
            "SELECT COUNT(*) FROM {$this->table_licenses} WHERE license_key = %s",
            $license_key
        ));

        if ($exists) {
            // License key already exists, skip insert.
            return false;
        }

        // 2. Insert with dynamic fields from the webhook payload
        return $this->wpdb->insert($this->table_licenses, [
            'license_key'         => $license_key,
            'customer_email'      => sanitize_email($license_data['user_email']),
            'product_item_name'   => sanitize_text_field($license_data['variant_name'] ?? 'My Awesome Plugin'),
            'license_tier'        => sanitize_text_field($license_data['status_formatted'] ?? 'Standard'),
            'allowed_activations' => absint($license_data['activation_limit'] ?? 1),
            'status'              => sanitize_text_field($license_data['status'] ?? 'active'),
            'created_at'          => current_time('mysql')
        ]);
    }
// ...
Enter fullscreen mode Exit fullscreen mode

This process ensures that every license key generated by the payment platform is securely recorded in your database, along with crucial details like the customer's email, the product name, the number of allowed activations, and the current status. This forms the authoritative source for all license information.

Part 2: The Client-Side Implementation - Activating Your WordPress Plugin

Now, let's shift our focus to your actual WordPress plugin. This is the part installed on your user's website, where they enter their license key, and the plugin initiates the validation process with your License Management Hub.

Checking License Status with PluginLicenseClient

The PluginLicenseClient class within your plugin is responsible for managing the local license state, communicating with the remote server, and caching validation results to optimize performance and reduce server load.

<?php
defined( 'ABSPATH' ) || exit;

class PluginLicenseClient {
    // ... private properties like $api_secret_token, $api_endpoint, $license_option_key, $status_option_key, $cache_transient_key, $plugin_slug

    private $api_secret_token = 'YOUR_SHARED_SECRET_FOR_CLIENT_SERVER'; // Must match server-side
    private $api_endpoint = 'https://your-license-hub.com/wp-json/my-licensing-api/v1/validate';
    private $license_option_key;
    private $status_option_key;
    private $cache_transient_key;
    private $plugin_slug;
    private $product_identifier = 'my-awesome-plugin-slug'; // Unique ID for your plugin

    public function __construct( $plugin_slug ) {
        $this->plugin_slug = $plugin_slug;
        $this->license_option_key = $this->plugin_slug . '_license_key';
        $this->status_option_key = $this->plugin_slug . '_license_status';
        $this->cache_transient_key = $this->plugin_slug . '_license_cache';
    }

    /**
     * Check if the plugin license is currently active.
     */
    public function is_license_active() {
        $cached_status = get_transient( $this->cache_transient_key );

        // If no cached status, perform remote validation
        if ( false === $cached_status ) {
            $license_key = get_option( $this->license_option_key );
            if ( empty( $license_key ) ) {
                return false; // No license key entered locally
            }
            $cached_status = $this->send_validation_request( $license_key );

            // Cache the result for a day if active, otherwise clear cache
            if ( 'active' === $cached_status ) {
                set_transient( $this->cache_transient_key, $cached_status, DAY_IN_SECONDS );
            } else {
                delete_transient( $this->cache_transient_key );
            }
        }

        return 'active' === $cached_status;
    }

    /**
     * Sends a request to the remote License Management Hub for validation.
     */
    public function send_validation_request( $license_key ) {
        $response = wp_remote_post(
            $this->api_endpoint,
            array(
                'timeout'    => 15,
                'user-agent' => $this->plugin_slug . '/1.0.0 (' . home_url() . ')',
                'headers'    => array(
                    'X-Auth-Token' => sanitize_text_field( $this->api_secret_token ), // Custom auth header
                    'Accept'       => 'application/json',
                ),
                'body'       => array(
                    'license_key'        => sanitize_text_field( $license_key ),
                    'product_identifier' => $this->product_identifier,
                    'site_url'           => home_url(),
                ),
            )
        );

        if ( is_wp_error( $response ) ) {
            error_log("License validation API error: " . $response->get_error_message());
            return 'error';
        }

        $body = json_decode( wp_remote_retrieve_body( $response ), true );

        if ( isset( $body['status'] ) && 'valid' === $body['status'] ) {
            update_option( $this->status_option_key, 'active' );
            return 'active';
        }

        update_option( $this->status_option_key, 'inactive' );
        return 'inactive';
    }

    // ... other methods like display_admin_notice, deactivate_local_license
}
Enter fullscreen mode Exit fullscreen mode

When is_license_active() is called, it first checks a WordPress transient (e.g., myplugin_license_cache). If no cached status is found, it proceeds to send_validation_request(). This method performs an HTTP POST request to your License Management Hub's /my-licensing-api/v1/validate endpoint. It sends the license_key, a product_identifier (a unique identifier for your plugin), and the site_url of the client site.

Important: The X-Auth-Token header contains a shared secret, $api_secret_token, which acts as an authorization key for the API request. This token is crucial for securing your validation endpoint and preventing unauthorized calls.

Part 3: The Server-Side Validation API - The Heart of WordPress Plugin License Activation

Back on the License Management Hub, the LicenseHubAPIHandler class handles the incoming validation requests from client plugins. This is the core of the WordPress Plugin License Activation process, where the legitimacy of a license and its domain usage are confirmed.

Handling Validation Requests with LicenseHubAPIHandler

The LicenseHubAPIHandler class registers the /my-licensing-api/v1/validate endpoint and defines how to process requests to it.

<?php
defined( 'ABSPATH' ) || exit;

class LicenseHubAPIHandler {
    private $plugin_base_slug;
    private $data_manager;
    // This secret must match the client-side token for API authentication
    private $api_shared_secret = 'YOUR_SHARED_SECRET_FOR_CLIENT_SERVER'; 

    public function __construct($plugin_base_slug) {
        $this->plugin_base_slug = $plugin_base_slug;
        $this->data_manager = new LicenseHubDataManager($this->plugin_base_slug);
    }

    public function register_api_routes() {
        register_rest_route('my-licensing-api/v1', '/validate', array(
            'methods'             => 'POST',
            'callback'            => array($this, 'handle_validation_request'),
            'permission_callback' => array($this, 'authenticate_api_request'), // Custom permission callback
            'args'                => array(
                'license_key'        => array('required' => true, 'sanitize_callback' => 'sanitize_text_field'),
                'product_identifier' => array('required' => true, 'sanitize_callback' => 'sanitize_text_field'),
                'site_url'           => array('required' => true, 'sanitize_callback' => 'esc_url_raw'),
            )
        ));
    }

    /**
     * Authenticates API requests using a shared token.
     */
    public function authenticate_api_request($request) {
        $auth_token = $request->get_header('X-Auth-Token');
        return hash_equals($auth_token, $this->api_shared_secret);
    }

    /**
     * Handles the incoming license validation request.
     */
    public function handle_validation_request($request) {
        $license_key        = sanitize_text_field($request->get_param('license_key'));
        $product_identifier = sanitize_text_field($request->get_param('product_identifier'));
        $site_url           = esc_url_raw($request->get_param('site_url'));

        if (empty($license_key) || empty($product_identifier) || empty($site_url)) {
            return new WP_Error('missing_params', __('Required fields are missing.', 'my-license-hub'), array('status' => 400));
        }

        $license_data = $this->data_manager->get_license_details($license_key, $product_identifier);

        // Check if license exists and is in an 'active' or 'inactive' (but not expired/revoked) state
        if (!$license_data || !in_array(strtolower($license_data->status), ['active', 'inactive'])) {
            return new WP_REST_Response(array(
                'status'  => 'invalid',
                'message' => __('License is invalid or does not exist.', 'my-license-hub')
            ), 200);
        }

        // Perform domain validation and activation logic
        $is_domain_allowed = $this->data_manager->validate_and_update_domain_activation($license_key, $site_url, $license_data);

        if (!$is_domain_allowed) {
            return new WP_REST_Response(array(
                'status'  => 'invalid',
                'message' => __('Domain activation limit reached for this license.', 'my-license-hub')
            ), 200);
        }

        return rest_ensure_response(array(
            'status'               => 'valid',
            'license_tier'         => sanitize_text_field($license_data->license_tier),
            'allowed_activations'  => (int) $license_data->allowed_activations,
            'active_domain_count'  => $this->data_manager->get_activated_domain_count($license_data),
            'created_at'           => mysql_to_rfc3339($license_data->created_at),
            'validation_checksum'  => wp_hash($license_key . $site_url . $this->api_shared_secret) // Simple checksum for client verification
        ));
    }
}
Enter fullscreen mode Exit fullscreen mode

License and Domain Validation Logic

The handle_validation_request method performs several critical checks:

  1. API Authentication: authenticate_api_request verifies the X-Auth-Token header to ensure the request is authorized.
  2. Parameter Validation: Ensures all required data (license_key, product_identifier, site_url) is present.
  3. License Existence: get_license_details fetches the license from the database.
  4. Domain Activation: The validate_and_update_domain_activation method is the core logic for managing domain limits.
<?php
// ... (part of LicenseHubDataManager class)

    /**
     * Validates a license key against a domain and manages activation limits.
     */
    public function validate_and_update_domain_activation($key, $current_domain_url, $license_data) {
        // Ensure license is found and is in an 'active' or 'inactive' state (not revoked/expired)
        if (!$license_data || !in_array(strtolower($license_data->status), ['active', 'inactive'])) {
            return false;
        }

        $activated_urls = !empty($license_data->activated_urls) ? explode(',', $license_data->activated_urls) : array();
        $activated_urls = array_map('trim', $activated_urls);

        // 1. If the current domain is already activated, it's valid.
        if (in_array($current_domain_url, $activated_urls)) {
            return true;
        }

        // 2. Check if there's room to add this new domain activation
        if (count($activated_urls) < (int) $license_data->allowed_activations) {
            $activated_urls[] = $current_domain_url;
            $new_list = implode(',', array_filter($activated_urls)); // Filter empty entries

            // Update the license record with the new activated domain
            $this->wpdb->update(
                $this->table_licenses,
                array(
                    'activated_urls' => $new_list,
                    'status'         => 'active' // Ensure status is 'active' once a domain is linked
                ),
                array('id' => $license_data->id)
            );
            return true;
        }

        // If domain not found and limit reached
        return false; 
    }

    /**
     * Helper to count currently activated domains for a license.
     */
    public function get_activated_domain_count($license_data) {
        if (empty($license_data->activated_urls)) {
            return 0;
        }
        $domains = explode(',', $license_data->activated_urls);
        return count(array_filter(array_map('trim', $domains)));
    }
// ...
Enter fullscreen mode Exit fullscreen mode

This validate_and_update_domain_activation method is pivotal. It first checks if the domain requesting validation is already activated under this license. If not, it then checks if there's available capacity to activate a new domain based on allowed_activations. If successful, it updates the activated_urls list in the database and ensures the license status is active.

The Complete WordPress Plugin License Activation Flow Summarized

Let's put all the pieces together and summarize the entire WordPress Plugin License Activation process:

  1. Purchase: A customer buys your premium plugin through your chosen payment platform (e.g., Lemon Squeezy).
  2. Webhook Trigger: The payment platform sends a license_key_created webhook to your License Management Hub.
  3. Hub Receives Webhook: LicenseHubWebhookReceiver::handle_incoming_webhook receives the webhook, securely verifies its signature, and parses the license data.
  4. License Stored: LicenseHubDataManager::insert_new_license stores the new license key, allowed activations, customer email, and other details in your hub's database.
  5. Client Plugin Activation: The user installs your plugin, enters their license key in their WordPress dashboard, and clicks "Activate."
  6. Client Initiates Validation: Your plugin's PluginLicenseClient::send_validation_request sends an HTTP POST request to your License Management Hub's API. This request includes the license key, your plugin's unique ID, and the client site's URL.
  7. Hub Validates Request: LicenseHubAPIHandler::handle_validation_request receives the request, authenticates it using the X-Auth-Token header, retrieves the license data from its database, and calls LicenseHubDataManager::validate_and_update_domain_activation.
  8. Domain Check & Activation: validate_and_update_domain_activation checks if the domain is already activated or if it can be added within the allowed_activations limit. If a new domain is activated, the database record is updated.
  9. Hub Responds: The API sends a valid or invalid status back to the client plugin, along with other license details.
  10. Client Updates Status: Your plugin receives the response, updates its local license status (e.g., in wp_options), and caches the result using transients (e.g., for 24 hours) to minimize future API calls.

This robust, complementary flow ensures that your licenses are securely managed, domain limits are enforced, and your plugin's premium functionality is exclusively available to legitimate users.

Key Takeaways for Robust WordPress Plugin License Activation

  • Security is Paramount: Always verify webhook signatures and API tokens to prevent fraudulent license activations and unauthorized access.
  • Centralized License Management: A dedicated License Management Hub simplifies tracking, updating, and revoking licenses across all your users.
  • Database as the Source of Truth: A well-structured license table (including license_key, allowed_activations, activated_urls, and status) is fundamental for WordPress Plugin License Activation.
  • Client-Server Synergy: The client plugin and server API must work hand-in-hand, with client-side caching to reduce the load on your License Management Hub.
  • Clear Separation of Concerns: Distinct logic for license creation (via webhooks) and license validation (via API requests) makes the system maintainable.

Implementing a system like this provides a solid and secure foundation for selling and managing your WordPress plugins, giving you control and ensuring your users have a smooth, reliable activation experience.

What are your thoughts?

Have you built similar license management systems for your WordPress plugins? What specific challenges did you encounter, and what innovative solutions did you implement? Share your experiences and insights in the comments below! If you found this in-depth analysis of WordPress Plugin License Activation helpful, consider following me for more technical content on plugin development and best practices.

Top comments (0)