Skip to main content

Integrate license keys and automatic updates

Use the Core Commerce license client API when you sell a WordPress plugin or theme through Core Forms. The client stores the customer's key on their site, activates that site against your store, checks for new releases, and requests a fresh signed ZIP URL when WordPress installs an update.

Before you add client code

On the WordPress site that sells the product:

  1. Enable Core Forms → Settings → License Issuing Server.
  2. Open Core Forms → Core Commerce → Products and create the product.
  3. Add at least one active pricing plan.
  4. Publish a verified ZIP in the product's Releases section.
  5. Copy the product slug from its product workspace.

Your client needs two non-secret values:

const STORE_URL    = 'https://store.example.com';
const PRODUCT_SLUG = 'your-plugin';

The customer license key is secret. Store it in a WordPress option, never include it in the plugin ZIP, source control, logs, telemetry, or a public URL.

Client endpoints

Core Forms exposes these EDD-compatible REST routes:

POST /wp-json/core-forms/v1/activate_license
POST /wp-json/core-forms/v1/check_license
POST /wp-json/core-forms/v1/deactivate_license
POST /wp-json/core-forms/v1/get_version

Every request sends:

Parameter Required Purpose
license Yes The customer's license key. license_key is also accepted.
item_name Yes The Core Commerce product slug. You may send item_id instead.
url For site state The licensed site's canonical home URL. site_url is also accepted.

Use HTTPS and wp_remote_post():

$response = wp_remote_post(
    'https://store.example.com/wp-json/core-forms/v1/activate_license',
    array(
        'timeout' => 15,
        'body'    => array(
            'license'   => $license_key,
            'item_name' => 'your-plugin',
            'url'       => home_url(),
        ),
    )
);

A successful activation includes the current site count, license limit, expiry, and remaining activations:

{
  "success": true,
  "license": "valid",
  "item_id": 12,
  "expires": "lifetime",
  "site_count": 1,
  "license_limit": 3,
  "activations_left": 2
}

An invalid, expired, or over-limit key returns success: false with a stable error value such as invalid_license or activation_limit_reached. Treat network errors as temporary. Do not silently deactivate a locally saved key because one request timed out.

Add a plugin update client

Add an Update URI header to the plugin's main file. This prevents a similarly named WordPress.org plugin from taking over its update channel.

/*
Plugin Name: Your Plugin
Version: 1.2.0
Update URI: https://store.example.com/your-plugin/
*/

The following reference class:

  • activates and deactivates the current site;
  • reports the locally stored key's status;
  • adds a Core Forms release to WordPress's normal Plugins update screen;
  • refreshes the signed package URL immediately before download.
<?php

final class Your_Plugin_License_Client {
    private const API_BASE       = 'https://store.example.com/wp-json/core-forms/v1/';
    private const PRODUCT_SLUG   = 'your-plugin';
    private const LICENSE_OPTION = 'your_plugin_license_key';
    private const VERSION        = '1.2.0';

    private string $plugin_file;

    public function __construct( string $plugin_file ) {
        $this->plugin_file = plugin_basename( $plugin_file );

        add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'check_for_update' ) );
        add_filter( 'plugins_api', array( $this, 'plugin_information' ), 20, 3 );
        add_filter( 'upgrader_pre_download', array( $this, 'refresh_package_before_download' ), 10, 4 );
    }

    public function activate( string $license_key ) {
        $license_key = sanitize_text_field( $license_key );
        $result      = $this->request( 'activate_license', $license_key );

        if ( ! is_wp_error( $result ) && ! empty( $result['success'] ) ) {
            update_option( self::LICENSE_OPTION, $license_key, false );
        }

        return $result;
    }

    public function deactivate() {
        $license_key = $this->license_key();
        $result      = $this->request( 'deactivate_license', $license_key );

        if ( ! is_wp_error( $result ) && ! empty( $result['success'] ) ) {
            delete_option( self::LICENSE_OPTION );
        }

        return $result;
    }

    public function status() {
        return $this->request( 'check_license', $this->license_key() );
    }

    public function check_for_update( $transient ) {
        if ( ! is_object( $transient ) || empty( $transient->checked[ $this->plugin_file ] ) ) {
            return $transient;
        }

        $release = $this->version_response();
        if (
            is_wp_error( $release )
            || empty( $release['success'] )
            || empty( $release['new_version'] )
            || ! version_compare( self::VERSION, $release['new_version'], '<' )
        ) {
            return $transient;
        }

        $update              = new stdClass();
        $update->slug        = self::PRODUCT_SLUG;
        $update->plugin      = $this->plugin_file;
        $update->new_version = (string) $release['new_version'];
        $update->package     = (string) ( $release['package'] ?? '' );
        $update->url         = 'https://store.example.com/your-plugin/';

        $transient->response[ $this->plugin_file ] = $update;

        return $transient;
    }

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

        $release = $this->version_response();
        if ( is_wp_error( $release ) || empty( $release['success'] ) ) {
            return $result;
        }

        return (object) array(
            'name'     => (string) ( $release['name'] ?? 'Your Plugin' ),
            'slug'     => self::PRODUCT_SLUG,
            'version'  => (string) $release['new_version'],
            'sections' => (array) ( $release['sections'] ?? array() ),
            'download_link' => (string) ( $release['download_link'] ?? '' ),
        );
    }

    public function refresh_package_before_download( $reply, string $package, $upgrader, array $hook_extra ) {
        if ( ( $hook_extra['plugin'] ?? '' ) !== $this->plugin_file ) {
            return $reply;
        }

        $release = $this->version_response();
        if ( is_wp_error( $release ) || empty( $release['package'] ) ) {
            return is_wp_error( $release )
                ? $release
                : new WP_Error( 'your_plugin_no_package', 'A licensed update package is not available.' );
        }

        if ( ! function_exists( 'download_url' ) ) {
            require_once ABSPATH . 'wp-admin/includes/file.php';
        }

        // Returning a temporary file lets WordPress continue its normal,
        // checksum-aware unzip and installation process.
        return download_url( (string) $release['package'], 300 );
    }

    public function version_response() {
        return $this->request( 'get_version', $this->license_key() );
    }

    private function license_key(): string {
        return trim( (string) get_option( self::LICENSE_OPTION, '' ) );
    }

    private function request( string $action, string $license_key ) {
        if ( '' === $license_key ) {
            return new WP_Error( 'your_plugin_missing_license', 'Enter a license key first.' );
        }

        $response = wp_remote_post(
            self::API_BASE . $action,
            array(
                'timeout' => 15,
                'body'    => array(
                    'license'   => $license_key,
                    'item_name' => self::PRODUCT_SLUG,
                    'url'       => home_url(),
                ),
            )
        );

        if ( is_wp_error( $response ) ) {
            return $response;
        }

        $status = wp_remote_retrieve_response_code( $response );
        $data   = json_decode( wp_remote_retrieve_body( $response ), true );

        if ( 200 !== $status || ! is_array( $data ) ) {
            return new WP_Error( 'your_plugin_license_response', 'The license server returned an invalid response.' );
        }

        return $data;
    }
}

new Your_Plugin_License_Client( __FILE__ );

Core Forms package links expire after 15 minutes. The upgrader_pre_download filter is therefore important: it asks get_version for a fresh signed URL at the moment WordPress downloads the ZIP instead of trusting an older update transient.

Use a nonce and an administrator capability such as manage_options around your own settings form before calling activate() or deactivate(). Escape server messages before displaying them.

Theme updates

Themes use the same four endpoints and request parameters. Replace the plugin update filter with pre_set_site_transient_update_themes and set the response entry by the theme stylesheet directory:

add_filter(
    'pre_set_site_transient_update_themes',
    function ( $transient ) use ( $client ) {
        $stylesheet = 'your-theme';
        $release    = $client->version_response();

        if (
            ! is_wp_error( $release )
            && ! empty( $release['success'] )
            && version_compare( wp_get_theme( $stylesheet )->get( 'Version' ), $release['new_version'], '<' )
        ) {
            $transient->response[ $stylesheet ] = array(
                'theme'       => $stylesheet,
                'new_version' => (string) $release['new_version'],
                'url'         => 'https://store.example.com/your-theme/',
                'package'     => (string) $release['package'],
            );
        }

        return $transient;
    }
);

Use the same pre-download refresh pattern for a theme so its package URL is current.

Test the complete update path

Test on a staging site before shipping:

  1. Save a valid key and activate the site. Confirm Core Commerce → Licenses → Activated sites increases by one.
  2. Call check_license and confirm license: valid for the same home_url().
  3. Publish a ZIP with a version higher than the installed client.
  4. Run wp transient delete --all or click Check again on Dashboard → Updates.
  5. Confirm WordPress offers the Core Commerce version.
  6. Install the update and confirm the expected version is active.
  7. Deactivate the key and confirm the site becomes inactive in Core Commerce.
  8. Repeat activation with a trailing slash or different URL case. It should update the same normalized site record, not consume another activation.

Troubleshooting

  • license_server_disabled: enable the License Issuing Server on the store.
  • invalid_license: the key is wrong, or it belongs to a different product.
  • activation_limit_reached: deactivate an old site or raise the license's activation limit.
  • site_inactive: the key is valid, but the current url has not been activated.
  • No update appears: verify that the product slug matches item_name, the published ZIP version is higher than the installed version, and the product has a current release.
  • Package link expired: request get_version again immediately before download. Do not cache the signed package URL.
  • ZIP rejected: ensure the plugin or theme header version matches the version entered in Core Commerce.