Files
wc-licensed-product-client/docs/client-implementation.md
magdev 760e1e752a Add update-check endpoint support (v0.2.1)
Implement /update-check endpoint aligned with remote OpenAPI spec:
- Add checkForUpdates() method to LicenseClientInterface
- Add UpdateInfo DTO for update check responses
- Add ProductNotFoundException for product_not_found error
- Update local openapi.json to v0.4.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:52:12 +01:00

31 KiB

Client-Side Implementation Guide

This document provides a comprehensive guide for integrating the WooCommerce Licensed Product Client into existing PHP projects. It covers architecture, classes, configuration, and best practices.

Overview

The magdev/wc-licensed-product-client library provides a complete PHP client for interacting with the WooCommerce Licensed Product REST API. It supports:

  • License validation, status checking, and activation
  • Response signature verification (HMAC-SHA256)
  • PSR-3 logging integration
  • PSR-6 caching integration
  • Comprehensive exception handling
  • Optional code integrity verification

Requirements

  • PHP: 8.3 or higher
  • Dependencies:
    • symfony/http-client ^7.0
    • psr/log ^3.0
    • psr/cache ^3.0
    • psr/http-client ^1.0

Installation

composer require magdev/wc-licensed-product-client

Architecture

Class Diagram

┌─────────────────────────────┐
│  LicenseClientInterface     │
│  (Public API Contract)      │
├─────────────────────────────┤
│ + validate()                │
│ + status()                  │
│ + activate()                │
│ + checkForUpdates()         │
└─────────────┬───────────────┘
              │ implements
              ├───────────────────────┐
              ▼                       ▼
┌─────────────────────┐   ┌──────────────────────┐
│   LicenseClient     │   │  SecureLicenseClient │
│   (Basic Client)    │   │  (With Signatures)   │
└─────────────────────┘   └──────────────────────┘
              │                       │
              └───────────┬───────────┘
                          ▼
              ┌─────────────────────┐
              │       DTOs          │
              ├─────────────────────┤
              │ LicenseInfo         │
              │ LicenseStatus       │
              │ ActivationResult    │
              │ UpdateInfo          │
              │ LicenseState (enum) │
              └─────────────────────┘

Directory Structure

src/
├── LicenseClientInterface.php    # Public API contract
├── LicenseClient.php             # Basic implementation
├── SecureLicenseClient.php       # Secure implementation with signatures
├── Dto/
│   ├── LicenseInfo.php           # Validation response DTO
│   ├── LicenseStatus.php         # Status response DTO + LicenseState enum
│   ├── ActivationResult.php      # Activation response DTO
│   └── UpdateInfo.php            # Update check response DTO
├── Exception/
│   ├── LicenseException.php      # Base exception
│   ├── LicenseNotFoundException.php
│   ├── LicenseExpiredException.php
│   ├── LicenseRevokedException.php
│   ├── LicenseInactiveException.php
│   ├── LicenseInvalidException.php
│   ├── DomainMismatchException.php
│   ├── MaxActivationsReachedException.php
│   ├── ActivationFailedException.php
│   ├── ProductNotFoundException.php
│   └── RateLimitExceededException.php
└── Security/
    ├── ResponseSignature.php     # HMAC signature verification
    ├── SignatureException.php    # Signature verification error
    ├── StringEncoder.php         # XOR-based string obfuscation
    ├── IntegrityChecker.php      # Source file hash verification
    └── IntegrityException.php    # Integrity check failure

Client Classes

LicenseClientInterface

The public API contract that both client implementations follow:

interface LicenseClientInterface
{
    /**
     * Validate a license key for a specific domain.
     *
     * @throws LicenseException When validation fails
     */
    public function validate(string $licenseKey, string $domain): LicenseInfo;

    /**
     * Get detailed status information for a license key.
     *
     * @throws LicenseException When status check fails
     */
    public function status(string $licenseKey): LicenseStatus;

    /**
     * Activate a license on a specific domain.
     *
     * @throws LicenseException When activation fails
     */
    public function activate(string $licenseKey, string $domain): ActivationResult;

    /**
     * Check for available plugin updates.
     *
     * @throws LicenseException When update check fails
     */
    public function checkForUpdates(
        string $licenseKey,
        string $domain,
        ?string $pluginSlug = null,
        ?string $currentVersion = null,
    ): UpdateInfo;
}

LicenseClient (Basic)

The basic client without signature verification. Use this when:

  • The server does not support response signing
  • You're in a development/testing environment
  • Security is handled at another layer

Constructor Parameters:

Parameter Type Required Default Description
httpClient HttpClientInterface Yes - Symfony HTTP client instance
baseUrl string Yes - WordPress site URL (e.g., https://example.com)
logger LoggerInterface|null No NullLogger PSR-3 logger for debugging
cache CacheItemPoolInterface|null No null PSR-6 cache for responses
cacheTtl int No 300 Cache TTL in seconds (5 minutes)

Example:

use Magdev\WcLicensedProductClient\LicenseClient;
use Symfony\Component\HttpClient\HttpClient;

$client = new LicenseClient(
    httpClient: HttpClient::create(),
    baseUrl: 'https://your-wordpress-site.com',
);

The secure client with response signature verification. Use this for production environments.

Constructor Parameters:

Parameter Type Required Default Description
httpClient HttpClientInterface Yes - Symfony HTTP client instance
baseUrl string Yes - WordPress site URL
serverSecret string Yes - Shared secret (same as server)
logger LoggerInterface|null No NullLogger PSR-3 logger
cache CacheItemPoolInterface|null No null PSR-6 cache
cacheTtl int No 300 Cache TTL in seconds
verifyIntegrity bool No false Enable source file integrity checks
encoder StringEncoder|null No Default instance Custom string encoder

Example:

use Magdev\WcLicensedProductClient\SecureLicenseClient;
use Symfony\Component\HttpClient\HttpClient;

$client = new SecureLicenseClient(
    httpClient: HttpClient::create(),
    baseUrl: 'https://your-wordpress-site.com',
    serverSecret: 'your-shared-server-secret-min-32-chars',
);

Security Features:

  1. Response Signature Verification: Every response is verified using HMAC-SHA256
  2. Timestamp Validation: Prevents replay attacks (5-minute tolerance)
  3. Per-License Key Derivation: Each license gets a unique signing key
  4. Obfuscated API Paths: API endpoint paths are XOR-encoded in source code
  5. Optional Integrity Checking: Verify critical source files haven't been modified

Data Transfer Objects (DTOs)

LicenseInfo

Returned by validate(). Contains license details for a valid license.

final readonly class LicenseInfo
{
    public function __construct(
        public int $productId,                    // WooCommerce product ID
        public ?\DateTimeImmutable $expiresAt,    // null = lifetime license
        public ?int $versionId,                   // Product version (if applicable)
    ) {}

    public static function fromArray(array $data): self;

    public function isLifetime(): bool;  // Returns true if no expiration
}

Usage:

$info = $client->validate('ABCD-1234-EFGH-5678', 'example.com');

echo "Product ID: " . $info->productId;
echo "Lifetime: " . ($info->isLifetime() ? 'Yes' : 'No');

if (!$info->isLifetime()) {
    echo "Expires: " . $info->expiresAt->format('Y-m-d');
}

LicenseStatus

Returned by status(). Contains comprehensive license information.

final readonly class LicenseStatus
{
    public function __construct(
        public bool $valid,                       // Is license currently valid
        public LicenseState $status,              // Status enum value
        public string $domain,                    // Bound domain
        public ?\DateTimeImmutable $expiresAt,    // Expiration date
        public int $activationsCount,             // Current activations
        public int $maxActivations,               // Maximum allowed
    ) {}

    public static function fromArray(array $data): self;

    public function isLifetime(): bool;

    public function hasAvailableActivations(): bool;
}

Usage:

$status = $client->status('ABCD-1234-EFGH-5678');

echo "Valid: " . ($status->valid ? 'Yes' : 'No');
echo "Status: " . $status->status->value;  // active, inactive, expired, revoked
echo "Domain: " . $status->domain;
echo "Activations: " . $status->activationsCount . "/" . $status->maxActivations;

if ($status->hasAvailableActivations()) {
    echo "More activations available!";
}

LicenseState (Enum)

Represents possible license states:

enum LicenseState: string
{
    case Active = 'active';       // License is active and valid
    case Inactive = 'inactive';   // License exists but not activated
    case Expired = 'expired';     // License has passed expiration date
    case Revoked = 'revoked';     // License was manually revoked
}

ActivationResult

Returned by activate(). Contains activation outcome.

final readonly class ActivationResult
{
    public function __construct(
        public bool $success,    // Whether activation succeeded
        public string $message,  // Human-readable message
    ) {}

    public static function fromArray(array $data): self;
}

Usage:

$result = $client->activate('ABCD-1234-EFGH-5678', 'newdomain.com');

if ($result->success) {
    echo "Activated: " . $result->message;
} else {
    echo "Failed: " . $result->message;
}

UpdateInfo

Returned by checkForUpdates(). Contains plugin update information.

final readonly class UpdateInfo
{
    public function __construct(
        public bool $updateAvailable,           // Whether an update is available
        public ?string $version,                // Latest available version
        public ?string $slug,                   // Plugin slug for WordPress
        public ?string $plugin,                 // Plugin basename (slug/slug.php)
        public ?string $downloadUrl,            // Secure download URL
        public ?\DateTimeImmutable $lastUpdated,// Date of the latest release
        public ?string $tested,                 // Highest WordPress version tested
        public ?string $requires,               // Minimum WordPress version
        public ?string $requiresPhp,            // Minimum PHP version
        public ?string $changelog,              // Release notes
        public ?string $packageHash,            // SHA256 hash for integrity
        public ?string $name,                   // Product name
        public ?string $homepage,               // Product homepage URL
        public ?array $icons,                   // Plugin icons for WordPress admin
        public ?array $sections,                // Content sections for plugin info
    ) {}

    public static function fromArray(array $data): self;

    public function hasValidPackageHash(): bool;  // Check package hash format
}

Usage:

$updateInfo = $client->checkForUpdates(
    'ABCD-1234-EFGH-5678',
    'example.com',
    'my-plugin',
    '1.0.0',
);

if ($updateInfo->updateAvailable) {
    echo "New version available: " . $updateInfo->version;
    echo "Download URL: " . $updateInfo->downloadUrl;

    if ($updateInfo->hasValidPackageHash()) {
        echo "Package hash: " . $updateInfo->packageHash;
    }
}

Exception Hierarchy

All exceptions extend LicenseException and include an errorCode property for programmatic handling.

LicenseException (base)
├── LicenseNotFoundException      // error: license_not_found
├── LicenseExpiredException       // error: license_expired
├── LicenseRevokedException       // error: license_revoked
├── LicenseInactiveException      // error: license_inactive
├── LicenseInvalidException       // error: license_invalid
├── DomainMismatchException       // error: domain_mismatch
├── MaxActivationsReachedException // error: max_activations_reached
├── ActivationFailedException     // error: activation_failed
├── ProductNotFoundException      // error: product_not_found
├── RateLimitExceededException    // error: rate_limit_exceeded (has retryAfter)
├── Security\SignatureException   // error: signature_invalid
└── Security\IntegrityException   // error: integrity_check_failed

Exception Properties

LicenseException (Base):

class LicenseException extends \RuntimeException
{
    public readonly ?string $errorCode;  // API error code for programmatic handling
}

RateLimitExceededException:

final class RateLimitExceededException extends LicenseException
{
    public readonly ?int $retryAfter;  // Seconds until rate limit resets
}

IntegrityException:

final class IntegrityException extends LicenseException
{
    public readonly array $failures;  // List of failed integrity checks
}

Catching Exceptions

use Magdev\WcLicensedProductClient\Exception\{
    LicenseException,
    LicenseNotFoundException,
    LicenseExpiredException,
    DomainMismatchException,
    RateLimitExceededException,
};
use Magdev\WcLicensedProductClient\Security\SignatureException;

try {
    $info = $client->validate($licenseKey, $domain);
} catch (SignatureException $e) {
    // Response was tampered with or server secret mismatch
    error_log("SECURITY: Response signature invalid");
    die("License verification failed. Please contact support.");
} catch (LicenseNotFoundException $e) {
    echo "License key not found.";
} catch (LicenseExpiredException $e) {
    echo "Your license has expired. Please renew.";
} catch (DomainMismatchException $e) {
    echo "This license is not valid for this domain.";
} catch (RateLimitExceededException $e) {
    echo "Too many requests. Try again in {$e->retryAfter} seconds.";
} catch (LicenseException $e) {
    // Catch-all for any license error
    echo "License error: " . $e->getMessage();
    error_log("License error [{$e->errorCode}]: " . $e->getMessage());
}

Security Classes

ResponseSignature

Verifies HMAC-SHA256 signatures on API responses to prevent tampering.

Key Derivation:

Each license key derives a unique signing key from the server secret:

// HKDF-like key derivation (internal implementation)
$prk = hash_hmac('sha256', $licenseKey, $serverSecret, true);
$derivedKey = hash_hmac('sha256', $prk . "\x01", $serverSecret);

Signature Format:

signature = HMAC-SHA256(
    key = derived_key,
    message = timestamp + ":" + canonical_json(response_body)
)

Where:

  • canonical_json sorts keys alphabetically
  • JSON uses JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE flags
  • Result is hex-encoded (64 characters)

Response Headers:

Header Description Example
X-License-Signature HMAC-SHA256 signature (hex) a1b2c3... (64 chars)
X-License-Timestamp Unix timestamp when signed 1706000000

Custom Timestamp Tolerance:

use Magdev\WcLicensedProductClient\Security\ResponseSignature;

// Default is 300 seconds (5 minutes)
$signature = new ResponseSignature($secretKey, timestampTolerance: 600);  // 10 minutes

StringEncoder

Simple XOR-based string obfuscation for hiding API paths in source code.

WARNING: This is NOT encryption. It only prevents casual inspection.

use Magdev\WcLicensedProductClient\Security\StringEncoder;

$encoder = new StringEncoder();

// Encode strings for embedding in source code
$encoded = $encoder->encode('/wp-json/wc-licensed-product/v1');
// Result: "MR4xBi8rPDUcHhk2EQ0TICsvMBo9Mw0HJRE="

// Decode at runtime
$decoded = $encoder->decode('MR4xBi8rPDUcHhk2EQ0TICsvMBo9Mw0HJRE=');
// Result: "/wp-json/wc-licensed-product/v1"

// Generate multiple encoded constants
$constants = $encoder->generateEncodedConstants([
    'API_PATH' => '/wp-json/wc-licensed-product/v1',
    'VALIDATE' => 'validate',
    'STATUS' => 'status',
]);

Custom Encoder Key:

$encoder = new StringEncoder('my_custom_encoder_key');

IntegrityChecker

Verifies that critical source files haven't been modified by comparing SHA256 hashes.

use Magdev\WcLicensedProductClient\Security\IntegrityChecker;

// Define expected hashes (generated during build)
$expectedHashes = [
    'src/SecureLicenseClient.php' => 'abc123...',
    'src/Security/ResponseSignature.php' => 'def456...',
];

$checker = new IntegrityChecker($expectedHashes, '/path/to/project');

// Verify all files
try {
    $checker->verify();
    echo "All files intact";
} catch (IntegrityException $e) {
    echo "Tampered files: " . implode(', ', $e->failures);
}

// Or check silently
if (!$checker->isValid()) {
    die("Critical files have been modified!");
}

Generating Hashes During Build:

$checker = new IntegrityChecker([], '/path/to/project');

$hashes = $checker->generateHashes([
    'src/SecureLicenseClient.php',
    'src/Security/ResponseSignature.php',
    'src/Security/SignatureException.php',
]);

// Output as PHP code for embedding
echo $checker->generateHashesAsPhpCode([
    'src/SecureLicenseClient.php',
    'src/Security/ResponseSignature.php',
]);

Caching

Both clients support PSR-6 caching to reduce API calls. Cache keys use SHA256 hashes of license keys to avoid exposing sensitive data.

Cache Key Format:

wc_license_{operation}_{sha256(license_key)}[_{sha256(domain)}]

Example with Symfony Cache:

use Symfony\Component\Cache\Adapter\FilesystemAdapter;

$cache = new FilesystemAdapter(
    namespace: 'license_client',
    defaultLifetime: 300,
    directory: '/tmp/license-cache'
);

$client = new SecureLicenseClient(
    httpClient: HttpClient::create(),
    baseUrl: 'https://example.com',
    serverSecret: 'your-secret',
    cache: $cache,
    cacheTtl: 600,  // 10 minutes
);

Cache Behavior:

  • validate() and status() responses are cached
  • activate() responses are NOT cached
  • After activate(), related cache entries are automatically invalidated

Logging

Both clients support PSR-3 logging for debugging and monitoring.

Log Levels Used:

Level When Used
debug Cache hits, signature verification success
info API calls, successful operations
warning API error responses, missing signatures
error Request failures, network errors
critical Integrity check failures

Example with Monolog:

use Monolog\Logger;
use Monolog\Handler\StreamHandler;

$logger = new Logger('license');
$logger->pushHandler(new StreamHandler('/var/log/license.log', Logger::DEBUG));

$client = new SecureLicenseClient(
    httpClient: HttpClient::create(),
    baseUrl: 'https://example.com',
    serverSecret: 'your-secret',
    logger: $logger,
);

Integration Guide

Basic Integration

The simplest integration for existing PHP projects:

<?php
// license-check.php

use Magdev\WcLicensedProductClient\SecureLicenseClient;
use Magdev\WcLicensedProductClient\Exception\LicenseException;
use Symfony\Component\HttpClient\HttpClient;

// Configuration (load from environment/config file)
$licenseKey = getenv('MY_PRODUCT_LICENSE_KEY');
$serverSecret = getenv('LICENSE_SERVER_SECRET');
$licenseServer = 'https://your-license-server.com';
$currentDomain = $_SERVER['HTTP_HOST'] ?? php_uname('n');

// Create client
$client = new SecureLicenseClient(
    httpClient: HttpClient::create(),
    baseUrl: $licenseServer,
    serverSecret: $serverSecret,
);

// Validate license
try {
    $info = $client->validate($licenseKey, $currentDomain);

    // Define constant for use throughout application
    define('MY_PRODUCT_LICENSED', true);
    define('MY_PRODUCT_ID', $info->productId);

} catch (LicenseException $e) {
    define('MY_PRODUCT_LICENSED', false);
    define('MY_PRODUCT_ID', null);

    // Log the error but don't expose details to users
    error_log("License validation failed: " . $e->getMessage());
}

WordPress Plugin Integration

<?php
/**
 * Plugin Name: My Licensed Plugin
 */

namespace MyPlugin;

use Magdev\WcLicensedProductClient\SecureLicenseClient;
use Magdev\WcLicensedProductClient\Exception\LicenseException;
use Symfony\Component\HttpClient\HttpClient;

class LicenseManager
{
    private const LICENSE_OPTION = 'my_plugin_license_key';
    private const SERVER_URL = 'https://your-license-server.com';
    private const SERVER_SECRET = 'your-server-secret';  // Or load from wp-config.php

    private ?SecureLicenseClient $client = null;

    public function __construct()
    {
        $this->client = new SecureLicenseClient(
            httpClient: HttpClient::create(),
            baseUrl: self::SERVER_URL,
            serverSecret: self::SERVER_SECRET,
        );
    }

    public function isLicenseValid(): bool
    {
        $licenseKey = get_option(self::LICENSE_OPTION);

        if (empty($licenseKey)) {
            return false;
        }

        // Use transient for caching
        $cacheKey = 'my_plugin_license_valid_' . md5($licenseKey);
        $cached = get_transient($cacheKey);

        if ($cached !== false) {
            return $cached === 'valid';
        }

        try {
            $domain = wp_parse_url(home_url(), PHP_URL_HOST);
            $this->client->validate($licenseKey, $domain);

            set_transient($cacheKey, 'valid', HOUR_IN_SECONDS);
            return true;

        } catch (LicenseException $e) {
            set_transient($cacheKey, 'invalid', 5 * MINUTE_IN_SECONDS);
            return false;
        }
    }

    public function activateLicense(string $licenseKey): array
    {
        try {
            $domain = wp_parse_url(home_url(), PHP_URL_HOST);
            $result = $this->client->activate($licenseKey, $domain);

            if ($result->success) {
                update_option(self::LICENSE_OPTION, $licenseKey);
                delete_transient('my_plugin_license_valid_' . md5($licenseKey));
            }

            return [
                'success' => $result->success,
                'message' => $result->message,
            ];

        } catch (LicenseException $e) {
            return [
                'success' => false,
                'message' => $e->getMessage(),
            ];
        }
    }
}

Laravel Integration

Service Provider:

<?php
// app/Providers/LicenseServiceProvider.php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Magdev\WcLicensedProductClient\LicenseClientInterface;
use Magdev\WcLicensedProductClient\SecureLicenseClient;
use Symfony\Component\HttpClient\HttpClient;
use Illuminate\Log\Logger;
use Illuminate\Cache\CacheManager;

class LicenseServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(LicenseClientInterface::class, function ($app) {
            return new SecureLicenseClient(
                httpClient: HttpClient::create(),
                baseUrl: config('services.license.url'),
                serverSecret: config('services.license.secret'),
                logger: $app->make(Logger::class),
                cache: $app->make(CacheManager::class)->store('file')->getStore(),
            );
        });
    }
}

Configuration (config/services.php):

return [
    // ...
    'license' => [
        'url' => env('LICENSE_SERVER_URL'),
        'secret' => env('LICENSE_SERVER_SECRET'),
    ],
];

Usage in Controller:

<?php

namespace App\Http\Controllers;

use Magdev\WcLicensedProductClient\LicenseClientInterface;
use Magdev\WcLicensedProductClient\Exception\LicenseException;

class LicenseController extends Controller
{
    public function __construct(
        private readonly LicenseClientInterface $licenseClient,
    ) {}

    public function validate(Request $request)
    {
        try {
            $info = $this->licenseClient->validate(
                $request->input('license_key'),
                $request->getHost(),
            );

            return response()->json([
                'valid' => true,
                'product_id' => $info->productId,
                'expires_at' => $info->expiresAt?->format('Y-m-d'),
            ]);

        } catch (LicenseException $e) {
            return response()->json([
                'valid' => false,
                'error' => $e->errorCode,
                'message' => $e->getMessage(),
            ], 403);
        }
    }
}

Symfony Integration

Service Configuration (config/services.yaml):

services:
    Magdev\WcLicensedProductClient\LicenseClientInterface:
        class: Magdev\WcLicensedProductClient\SecureLicenseClient
        arguments:
            $httpClient: '@Symfony\Contracts\HttpClient\HttpClientInterface'
            $baseUrl: '%env(LICENSE_SERVER_URL)%'
            $serverSecret: '%env(LICENSE_SERVER_SECRET)%'
            $logger: '@logger'
            $cache: '@cache.app'

Best Practices

1. Use the Secure Client in Production

Always use SecureLicenseClient in production to verify response signatures:

// Development
$client = new LicenseClient(...);

// Production
$client = new SecureLicenseClient(..., serverSecret: $secret);

2. Handle All Exception Types

Don't catch just the base exception. Handle specific cases:

try {
    $client->validate($key, $domain);
} catch (SignatureException $e) {
    // Critical: possible tampering
    notifyAdmin("Signature verification failed!");
    die("Security error");
} catch (RateLimitExceededException $e) {
    // Retry later
    sleep($e->retryAfter ?? 60);
    retry();
} catch (LicenseExpiredException $e) {
    // Prompt renewal
    showRenewalForm();
} catch (LicenseException $e) {
    // Generic handling
    showError($e->getMessage());
}

3. Cache Validation Results

Use caching to minimize API calls:

$cache = new FilesystemAdapter('licenses', 300);
$client = new SecureLicenseClient(..., cache: $cache, cacheTtl: 300);

4. Store Secrets Securely

Never hardcode secrets in source code:

// Bad
$client = new SecureLicenseClient(..., serverSecret: 'abc123');

// Good
$client = new SecureLicenseClient(..., serverSecret: getenv('LICENSE_SECRET'));

5. Validate on Application Startup

Check license validity early, not on every request:

// bootstrap.php
$isLicensed = checkLicense();
define('APP_LICENSED', $isLicensed);

// Later in code
if (APP_LICENSED) {
    showPremiumFeatures();
}

6. Log License Events

Enable logging for debugging and audit trails:

$client = new SecureLicenseClient(
    ...,
    logger: new Logger('license'),
);

7. Implement Graceful Degradation

Don't crash the entire application on license failure:

try {
    $info = $client->validate($key, $domain);
    enablePremiumFeatures($info);
} catch (LicenseException $e) {
    // Fall back to free tier
    enableFreeFeatures();
    logLicenseIssue($e);
}

API Reference

API Endpoints

The client communicates with these REST API endpoints:

Endpoint Method Description
/wp-json/wc-licensed-product/v1/validate POST Validate license for domain
/wp-json/wc-licensed-product/v1/status POST Get license status details
/wp-json/wc-licensed-product/v1/activate POST Activate license on domain
/wp-json/wc-licensed-product/v1/update-check POST Check for plugin updates

Request Format

All requests use JSON bodies:

{
    "license_key": "ABCD-1234-EFGH-5678",
    "domain": "example.com"
}

Response Format

Successful Validation:

{
    "valid": true,
    "license": {
        "product_id": 123,
        "expires_at": "2027-01-21",
        "version_id": null
    }
}

Error Response:

{
    "valid": false,
    "error": "license_expired",
    "message": "This license has expired."
}

Error Codes

Error Code Exception Class Description
license_not_found LicenseNotFoundException License key doesn't exist
license_expired LicenseExpiredException License past expiration date
license_revoked LicenseRevokedException License manually revoked
license_inactive LicenseInactiveException License not activated
license_invalid LicenseInvalidException License invalid for operation
domain_mismatch DomainMismatchException Domain not authorized
max_activations_reached MaxActivationsReachedException Too many activations
activation_failed ActivationFailedException Server error during activation
product_not_found ProductNotFoundException Licensed product doesn't exist
rate_limit_exceeded RateLimitExceededException Too many requests
signature_invalid SignatureException Response signature invalid
integrity_check_failed IntegrityException Source files modified

Troubleshooting

"Response is not signed by the server"

  • Server not configured with WC_LICENSE_SERVER_SECRET
  • Using SecureLicenseClient with non-signing server
  • Solution: Configure server secret or use LicenseClient

"Response signature verification failed"

  • Different secrets on server/client
  • Clock skew > 5 minutes between server and client
  • Response modified by proxy/cache
  • Solution: Verify secrets match, sync clocks, disable response caching

Connection Timeouts

  • Network issues between client and license server
  • Solution: Configure HTTP client timeout
$httpClient = HttpClient::create([
    'timeout' => 30,
    'max_duration' => 60,
]);

Rate Limiting

  • Too many requests from same IP
  • Solution: Implement caching, handle RateLimitExceededException
catch (RateLimitExceededException $e) {
    $waitSeconds = $e->retryAfter ?? 60;
    // Implement exponential backoff
}