# Server-Side Implementation Guide This document describes how to implement the WooCommerce Licensed Product REST API with response signing. The API allows external applications to validate, check status, and activate software licenses. ## Overview The license server provides: 1. **REST API endpoints** for license operations 2. **Response signing** using HMAC-SHA256 for tamper protection 3. **Rate limiting** to prevent abuse 4. **Comprehensive error responses** for programmatic handling ### Security Model The signature system prevents attackers from: - Faking valid license responses - Replaying old responses (timestamp validation) - Tampering with response data in transit ## Requirements - PHP 8.3 or higher (to match client requirements) - WordPress 6.0+ with WooCommerce - A server secret stored securely (not in version control) ## Server Configuration ### 1. Store the Server Secret Add a secret key to your WordPress configuration: ```php // wp-config.php define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars'); ``` Generate a secure secret: ```bash # Using OpenSSL openssl rand -hex 32 # Or using PHP php -r "echo bin2hex(random_bytes(32));" ``` **IMPORTANT:** Never commit this secret to version control! ### 2. Configure Rate Limiting (Optional) ```php // wp-config.php define('WC_LICENSE_RATE_LIMIT', 30); // Requests per window define('WC_LICENSE_RATE_WINDOW', 60); // Window in seconds ``` ## API Endpoints Base URL: `{site_url}/wp-json/wc-licensed-product/v1` ### POST /validate Validate a license key for a specific domain. **Request:** ```json { "license_key": "ABCD-1234-EFGH-5678", "domain": "example.com" } ``` **Success Response (200):** ```json { "valid": true, "license": { "product_id": 123, "expires_at": "2027-01-21", "version_id": null } } ``` **Error Response (403):** ```json { "valid": false, "error": "license_expired", "message": "This license has expired." } ``` ### POST /status Get detailed license status information. **Request:** ```json { "license_key": "ABCD-1234-EFGH-5678" } ``` **Success Response (200):** ```json { "valid": true, "status": "active", "domain": "example.com", "expires_at": "2027-01-21", "activations_count": 1, "max_activations": 3 } ``` ### POST /activate Activate a license on a specific domain. **Request:** ```json { "license_key": "ABCD-1234-EFGH-5678", "domain": "newdomain.com" } ``` **Success Response (200):** ```json { "success": true, "message": "License activated successfully." } ``` **Error Response (403):** ```json { "success": false, "error": "max_activations_reached", "message": "Maximum number of activations reached." } ``` ### POST /update-check Check for plugin updates for a licensed product. **Request:** ```json { "license_key": "ABCD-1234-EFGH-5678", "domain": "example.com", "plugin_slug": "my-licensed-plugin", "current_version": "1.0.0" } ``` **Success Response (200) - Update Available:** ```json { "success": true, "update_available": true, "version": "1.2.0", "slug": "my-licensed-plugin", "plugin": "my-licensed-plugin/my-licensed-plugin.php", "download_url": "https://example.com/license-download/123-456-abc123", "package": "https://example.com/license-download/123-456-abc123", "last_updated": "2026-01-27", "tested": "6.7", "requires": "6.0", "requires_php": "8.3", "changelog": "## 1.2.0\n- New feature added\n- Bug fixes", "package_hash": "sha256:abc123def456...", "name": "My Licensed Plugin", "homepage": "https://example.com/product/my-plugin" } ``` **Success Response (200) - No Update:** ```json { "success": true, "update_available": false, "version": "1.0.0" } ``` **Error Response (404):** ```json { "success": false, "update_available": false, "error": "product_not_found", "message": "Licensed product not found." } ``` ## Error Codes The API uses consistent error codes for programmatic handling: | Error Code | HTTP Status | Description | | ---------- | ----------- | ----------- | | `license_not_found` | 404 | License key doesn't exist | | `license_expired` | 403 | License past expiration date | | `license_revoked` | 403 | License manually revoked by admin | | `license_inactive` | 403 | License not yet activated | | `license_invalid` | 403 | License invalid for requested operation | | `domain_mismatch` | 403 | License not authorized for this domain | | `max_activations_reached` | 403 | Maximum activations limit exceeded | | `activation_failed` | 500 | Server error during activation | | `product_not_found` | 404 | Licensed product not found | | `rate_limit_exceeded` | 429 | Too many requests | ### Rate Limit Response When rate limit is exceeded, include `retry_after`: ```json { "success": false, "error": "rate_limit_exceeded", "message": "Too many requests. Please try again later.", "retry_after": 45 } ``` Also include the `Retry-After` HTTP header. ## Response Signing Implementation ### Key Derivation Each license key gets a unique signing key derived from the server secret using RFC 5869 HKDF: ```php /** * Derive a unique signing key for a license. * * Uses RFC 5869 HKDF for secure key derivation. * * @param string $licenseKey The license key * @param string $serverSecret The server's master secret * @return string The derived key (hex encoded, 64 chars) */ function derive_signing_key(string $licenseKey, string $serverSecret): string { // Use PHP's native HKDF implementation (RFC 5869) // - IKM (input keying material): server secret // - Length: 32 bytes (256 bits for SHA-256) // - Info: license key (context-specific info) // - Salt: empty (uses hash-length zero bytes as per RFC 5869) $binaryKey = hash_hkdf('sha256', $serverSecret, 32, $licenseKey); return bin2hex($binaryKey); } ``` ### Recursive Key Sorting **IMPORTANT:** Response data must have keys sorted recursively for consistent signatures: ```php /** * Recursively sort array keys for consistent JSON output. * * @param array $data The data to sort * @return array Sorted data */ function sort_keys_recursive(array $data): array { ksort($data); foreach ($data as $key => $value) { if (is_array($value)) { $data[$key] = sort_keys_recursive($value); } } return $data; } ``` ### Response Signing Sign every API response before sending: ```php /** * Sign an API response. * * @param array $responseData The response body (before JSON encoding) * @param string $licenseKey The license key from the request * @param string $serverSecret The server's master secret * @return array Headers to add to the response */ function sign_response(array $responseData, string $licenseKey, string $serverSecret): array { $timestamp = time(); $signingKey = derive_signing_key($licenseKey, $serverSecret); // Sort keys recursively for consistent ordering $sortedData = sort_keys_recursive($responseData); // Build signature payload $jsonBody = json_encode($sortedData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $payload = $timestamp . ':' . $jsonBody; // Generate HMAC signature $signature = hash_hmac('sha256', $payload, $signingKey); return [ 'X-License-Signature' => $signature, 'X-License-Timestamp' => (string) $timestamp, ]; } ``` ### Signature Algorithm ```text signature = HMAC-SHA256( key = derive_signing_key(license_key, server_secret), message = timestamp + ":" + canonical_json(response_body) ) ``` Where: - `derive_signing_key` uses RFC 5869 HKDF via `hash_hkdf()` - `canonical_json` sorts keys recursively, uses `JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE` - Result is hex-encoded (64 characters) ### Response Headers Every signed response includes: | Header | Description | Example | | ------ | ----------- | ------- | | `X-License-Signature` | HMAC-SHA256 signature (hex) | `a1b2c3d4...` (64 chars) | | `X-License-Timestamp` | Unix timestamp when signed | `1706000000` | ## Complete WordPress Plugin Implementation ```php serverSecret = defined('WC_LICENSE_SERVER_SECRET') ? WC_LICENSE_SERVER_SECRET : ''; $this->rateLimit = defined('WC_LICENSE_RATE_LIMIT') ? (int) WC_LICENSE_RATE_LIMIT : 30; $this->rateWindow = defined('WC_LICENSE_RATE_WINDOW') ? (int) WC_LICENSE_RATE_WINDOW : 60; } public function register(): void { add_action('rest_api_init', [$this, 'registerRoutes']); add_filter('rest_post_dispatch', [$this, 'signResponse'], 10, 3); } public function registerRoutes(): void { register_rest_route(self::API_NAMESPACE, '/validate', [ 'methods' => 'POST', 'callback' => [$this, 'handleValidate'], 'permission_callback' => [$this, 'checkRateLimit'], 'args' => $this->getValidateArgs(), ]); register_rest_route(self::API_NAMESPACE, '/status', [ 'methods' => 'POST', 'callback' => [$this, 'handleStatus'], 'permission_callback' => [$this, 'checkRateLimit'], 'args' => $this->getStatusArgs(), ]); register_rest_route(self::API_NAMESPACE, '/activate', [ 'methods' => 'POST', 'callback' => [$this, 'handleActivate'], 'permission_callback' => [$this, 'checkRateLimit'], 'args' => $this->getActivateArgs(), ]); register_rest_route(self::API_NAMESPACE, '/update-check', [ 'methods' => 'POST', 'callback' => [$this, 'handleUpdateCheck'], 'permission_callback' => [$this, 'checkRateLimit'], 'args' => $this->getUpdateCheckArgs(), ]); } // ========================================================================= // Request Validation // ========================================================================= private function getValidateArgs(): array { return [ 'license_key' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => [$this, 'validateLicenseKeyFormat'], ], 'domain' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => [$this, 'validateDomainFormat'], ], ]; } private function getStatusArgs(): array { return [ 'license_key' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => [$this, 'validateLicenseKeyFormat'], ], ]; } private function getActivateArgs(): array { return $this->getValidateArgs(); // Same as validate } private function getUpdateCheckArgs(): array { return [ 'license_key' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => [$this, 'validateLicenseKeyFormat'], ], 'domain' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => [$this, 'validateDomainFormat'], ], 'plugin_slug' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'current_version' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], ]; } public function validateLicenseKeyFormat($value): bool { return is_string($value) && strlen($value) <= 64 && strlen($value) >= 8; } public function validateDomainFormat($value): bool { return is_string($value) && strlen($value) <= 255 && strlen($value) >= 1; } // ========================================================================= // Rate Limiting // ========================================================================= public function checkRateLimit(\WP_REST_Request $request): bool|\WP_Error { $ip = $this->getClientIp(); $key = 'license_rate_' . md5($ip); $data = get_transient($key); if ($data === false) { // First request in window set_transient($key, ['count' => 1, 'start' => time()], $this->rateWindow); return true; } if ($data['count'] >= $this->rateLimit) { $retryAfter = $this->rateWindow - (time() - $data['start']); return new \WP_Error( 'rate_limit_exceeded', 'Too many requests. Please try again later.', [ 'status' => 429, 'retry_after' => max(1, $retryAfter), ] ); } // Increment counter $data['count']++; set_transient($key, $data, $this->rateWindow - (time() - $data['start'])); return true; } private function getClientIp(): string { $headers = [ 'HTTP_CF_CONNECTING_IP', // Cloudflare 'HTTP_X_FORWARDED_FOR', // Proxy 'HTTP_X_REAL_IP', // Nginx 'REMOTE_ADDR', // Direct ]; foreach ($headers as $header) { if (!empty($_SERVER[$header])) { $ip = $_SERVER[$header]; // Handle comma-separated list (X-Forwarded-For) if (str_contains($ip, ',')) { $ip = trim(explode(',', $ip)[0]); } if (filter_var($ip, FILTER_VALIDATE_IP)) { return $ip; } } } return '0.0.0.0'; } // ========================================================================= // API Handlers // ========================================================================= public function handleValidate(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $licenseKey = $request->get_param('license_key'); $domain = $request->get_param('domain'); // TODO: Replace with your license lookup logic $license = $this->findLicense($licenseKey); if ($license === null) { return $this->errorResponse('license_not_found', 'License key not found.', 404); } if ($license['status'] === 'revoked') { return $this->errorResponse('license_revoked', 'This license has been revoked.', 403); } if ($license['status'] === 'expired' || $this->isExpired($license)) { return $this->errorResponse('license_expired', 'This license has expired.', 403); } if ($license['status'] === 'inactive') { return $this->errorResponse('license_inactive', 'This license is inactive.', 403); } if (!empty($license['domain']) && $license['domain'] !== $domain) { return $this->errorResponse('domain_mismatch', 'This license is not valid for this domain.', 403); } return new \WP_REST_Response([ 'valid' => true, 'license' => [ 'product_id' => $license['product_id'], 'expires_at' => $license['expires_at'], 'version_id' => $license['version_id'] ?? null, ], ], 200); } public function handleStatus(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $licenseKey = $request->get_param('license_key'); $license = $this->findLicense($licenseKey); if ($license === null) { return $this->errorResponse('license_not_found', 'License key not found.', 404); } $status = $license['status']; if ($status === 'active' && $this->isExpired($license)) { $status = 'expired'; } return new \WP_REST_Response([ 'valid' => $status === 'active', 'status' => $status, 'domain' => $license['domain'] ?? '', 'expires_at' => $license['expires_at'], 'activations_count' => $license['activations_count'] ?? 0, 'max_activations' => $license['max_activations'] ?? 1, ], 200); } public function handleActivate(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $licenseKey = $request->get_param('license_key'); $domain = $request->get_param('domain'); $license = $this->findLicense($licenseKey); if ($license === null) { return $this->errorResponse('license_not_found', 'License key not found.', 404); } if ($license['status'] === 'revoked') { return $this->errorResponse('license_invalid', 'This license is not valid.', 403); } if ($this->isExpired($license)) { return $this->errorResponse('license_invalid', 'This license has expired.', 403); } // Check if already activated on this domain if ($license['domain'] === $domain) { return new \WP_REST_Response([ 'success' => true, 'message' => 'License is already activated for this domain.', ], 200); } // Check activation limit $activations = $license['activations_count'] ?? 0; $maxActivations = $license['max_activations'] ?? 1; if ($activations >= $maxActivations && $license['domain'] !== $domain) { return $this->errorResponse( 'max_activations_reached', 'Maximum number of activations reached.', 403 ); } // TODO: Replace with your activation logic $activated = $this->activateLicense($licenseKey, $domain); if (!$activated) { return $this->errorResponse('activation_failed', 'Failed to activate license.', 500); } return new \WP_REST_Response([ 'success' => true, 'message' => 'License activated successfully.', ], 200); } public function handleUpdateCheck(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $licenseKey = $request->get_param('license_key'); $domain = $request->get_param('domain'); $pluginSlug = $request->get_param('plugin_slug'); $currentVersion = $request->get_param('current_version'); $license = $this->findLicense($licenseKey); if ($license === null) { return $this->errorResponse('license_not_found', 'License key not found.', 404); } if ($license['status'] !== 'active' || $this->isExpired($license)) { return $this->errorResponse('license_invalid', 'License validation failed.', 403); } if (!empty($license['domain']) && $license['domain'] !== $domain) { return $this->errorResponse('domain_mismatch', 'This license is not valid for this domain.', 403); } // TODO: Replace with your product lookup logic $product = $this->findProduct($license['product_id']); if ($product === null) { return $this->errorResponse('product_not_found', 'Licensed product not found.', 404); } $latestVersion = $product['version'] ?? '1.0.0'; $updateAvailable = $currentVersion !== null && version_compare($currentVersion, $latestVersion, '<'); $response = [ 'success' => true, 'update_available' => $updateAvailable, 'version' => $latestVersion, ]; if ($updateAvailable) { $response['slug'] = $product['slug'] ?? $pluginSlug; $response['plugin'] = ($product['slug'] ?? $pluginSlug) . '/' . ($product['slug'] ?? $pluginSlug) . '.php'; $response['download_url'] = $product['download_url'] ?? null; $response['package'] = $product['download_url'] ?? null; $response['last_updated'] = $product['last_updated'] ?? null; $response['tested'] = $product['tested'] ?? null; $response['requires'] = $product['requires'] ?? null; $response['requires_php'] = $product['requires_php'] ?? null; $response['changelog'] = $product['changelog'] ?? null; $response['package_hash'] = $product['package_hash'] ?? null; $response['name'] = $product['name'] ?? null; $response['homepage'] = $product['homepage'] ?? null; } return new \WP_REST_Response($response, 200); } private function errorResponse(string $code, string $message, int $status): \WP_REST_Response { $data = [ 'valid' => false, 'success' => false, 'error' => $code, 'message' => $message, ]; if ($code === 'rate_limit_exceeded') { $data['retry_after'] = $this->rateWindow; } return new \WP_REST_Response($data, $status); } // ========================================================================= // Response Signing // ========================================================================= public function signResponse( \WP_REST_Response $response, \WP_REST_Server $server, \WP_REST_Request $request ): \WP_REST_Response { if (!$this->shouldSign($request)) { return $response; } $data = $response->get_data(); $licenseKey = $request->get_param('license_key'); if (empty($licenseKey) || !is_array($data) || empty($this->serverSecret)) { return $response; } $headers = $this->createSignatureHeaders($data, $licenseKey); foreach ($headers as $name => $value) { $response->header($name, $value); } return $response; } private function shouldSign(\WP_REST_Request $request): bool { $route = $request->get_route(); return str_starts_with($route, '/' . self::API_NAMESPACE . '/'); } private function createSignatureHeaders(array $data, string $licenseKey): array { $timestamp = time(); $signingKey = $this->deriveKey($licenseKey); // Sort keys recursively for consistent ordering $sortedData = $this->sortKeysRecursive($data); $payload = $timestamp . ':' . json_encode( $sortedData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); return [ 'X-License-Signature' => hash_hmac('sha256', $payload, $signingKey), 'X-License-Timestamp' => (string) $timestamp, ]; } private function sortKeysRecursive(array $data): array { ksort($data); foreach ($data as $key => $value) { if (is_array($value)) { $data[$key] = $this->sortKeysRecursive($value); } } return $data; } private function deriveKey(string $licenseKey): string { // RFC 5869 HKDF key derivation $binaryKey = hash_hkdf('sha256', $this->serverSecret, 32, $licenseKey); return bin2hex($binaryKey); } // ========================================================================= // License Storage (Replace with your implementation) // ========================================================================= /** * Find a license by key. * * TODO: Replace with your database/WooCommerce lookup * * @return array|null License data or null if not found */ private function findLicense(string $licenseKey): ?array { // Example structure - replace with actual database lookup // return [ // 'license_key' => 'ABCD-1234-EFGH-5678', // 'product_id' => 123, // 'status' => 'active', // active, inactive, expired, revoked // 'domain' => 'example.com', // 'expires_at' => '2027-01-21', // or null for lifetime // 'version_id' => null, // 'activations_count' => 1, // 'max_activations' => 3, // ]; return null; // Replace with actual implementation } /** * Activate a license on a domain. * * TODO: Replace with your database update logic */ private function activateLicense(string $licenseKey, string $domain): bool { // Update license record with new domain // Increment activations_count // Set status to 'active' return false; // Replace with actual implementation } /** * Find a product by ID. * * TODO: Replace with your WooCommerce product lookup * * @return array|null Product data or null if not found */ private function findProduct(int $productId): ?array { // Example structure - replace with actual database lookup // return [ // 'product_id' => 123, // 'name' => 'My Licensed Plugin', // 'slug' => 'my-licensed-plugin', // 'version' => '1.2.0', // 'download_url' => 'https://example.com/license-download/123-abc', // 'last_updated' => '2026-01-27', // 'tested' => '6.7', // 'requires' => '6.0', // 'requires_php' => '8.3', // 'changelog' => '## 1.2.0\n- New feature\n- Bug fixes', // 'package_hash' => 'sha256:abc123...', // 'homepage' => 'https://example.com/product/my-plugin', // ]; return null; // Replace with actual implementation } private function isExpired(?array $license): bool { if ($license === null || empty($license['expires_at'])) { return false; // Lifetime license } return strtotime($license['expires_at']) < time(); } } // Initialize plugin add_action('plugins_loaded', function () { (new LicenseApi())->register(); }); ``` ## Testing ### Verify Signing Works ```php // Test script $serverSecret = 'test-secret-key-for-development-only'; $licenseKey = 'ABCD-1234-EFGH-5678'; $responseData = [ 'valid' => true, 'license' => [ 'product_id' => 123, 'expires_at' => '2027-01-21', 'version_id' => null, ], ]; $headers = sign_response($responseData, $licenseKey, $serverSecret); echo "X-License-Signature: " . $headers['X-License-Signature'] . "\n"; echo "X-License-Timestamp: " . $headers['X-License-Timestamp'] . "\n"; ``` ### Test with Client ```php use Magdev\WcLicensedProductClient\SecureLicenseClient; use Magdev\WcLicensedProductClient\Security\SignatureException; use Symfony\Component\HttpClient\HttpClient; $client = new SecureLicenseClient( httpClient: HttpClient::create(), baseUrl: 'https://your-site.com', serverSecret: 'same-secret-as-server', ); try { $info = $client->validate('ABCD-1234-EFGH-5678', 'example.com'); echo "License valid! Product ID: " . $info->productId; } catch (SignatureException $e) { echo "Signature verification failed - possible tampering!"; } ``` ### Verify Signature Manually ```bash # Using curl to test the API curl -X POST https://your-site.com/wp-json/wc-licensed-product/v1/validate \ -H "Content-Type: application/json" \ -d '{"license_key":"ABCD-1234-EFGH-5678","domain":"example.com"}' \ -i # Check for X-License-Signature and X-License-Timestamp headers ``` ## Security Considerations ### Timestamp Tolerance The client allows a 5-minute window for timestamp verification. This: - Prevents replay attacks (old responses rejected) - Allows for reasonable clock skew between server and client Adjust if needed: ```php // Client-side: custom tolerance use Magdev\WcLicensedProductClient\Security\ResponseSignature; $signature = new ResponseSignature($key, timestampTolerance: 600); // 10 minutes ``` ### Secret Key Rotation To rotate the server secret: 1. Deploy new secret to server 2. Update client configurations 3. Old signatures become invalid immediately For zero-downtime rotation, implement versioned secrets: ```php // Server supports both old and new secrets during transition $secrets = [ 'v2' => 'new-secret', 'v1' => 'old-secret', ]; // Add version to signature header $response->header('X-License-Signature-Version', 'v2'); ``` ### Sign Error Responses Sign error responses too! Otherwise attackers could craft fake error messages: ```php // Both success and error responses are signed by the filter // No additional code needed - the signResponse filter handles all responses ``` ### HTTPS Required Always serve the API over HTTPS: ```php // In your plugin, enforce HTTPS add_action('rest_api_init', function() { if (!is_ssl() && !defined('WP_DEBUG') || !WP_DEBUG) { wp_die('License API requires HTTPS'); } }); ``` ### Input Sanitization Always sanitize and validate input: ```php // The WordPress REST API args handle this automatically // But add additional validation as needed $licenseKey = sanitize_text_field($request->get_param('license_key')); $domain = sanitize_text_field($request->get_param('domain')); // Validate format if (!preg_match('/^[A-Z0-9\-]{8,64}$/i', $licenseKey)) { return new WP_Error('invalid_license_key', 'Invalid license key format'); } ``` ## Troubleshooting ### "Response is not signed by the server" - Server not configured with `WC_LICENSE_SERVER_SECRET` - Filter not registered (check plugin activation) - Route mismatch (check `shouldSign()` paths) ### "Response signature verification failed" - Different secrets on server/client - Clock skew > 5 minutes - Response body modified after signing (e.g., by caching plugin) - JSON encoding differences (check recursive `ksort` and flags) - Nested objects not sorted consistently ### Debugging Enable detailed logging: ```php // Server-side debugging add_filter('rest_post_dispatch', function($response, $server, $request) { if (!str_starts_with($request->get_route(), '/wc-licensed-product/v1/')) { return $response; } $data = $response->get_data(); $sortedData = sort_keys_recursive($data); $json = json_encode($sortedData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); error_log('License API Debug:'); error_log('Route: ' . $request->get_route()); error_log('License Key: ' . $request->get_param('license_key')); error_log('Sorted JSON: ' . $json); error_log('Timestamp: ' . time()); return $response; }, 5, 3); ``` ```php // Client-side: use a PSR-3 logger $client = new SecureLicenseClient( // ... logger: new YourDebugLogger(), ); ``` ### Rate Limit Issues If legitimate users hit rate limits: ```php // Increase limits in wp-config.php define('WC_LICENSE_RATE_LIMIT', 60); // 60 requests define('WC_LICENSE_RATE_WINDOW', 60); // per minute // Or implement per-license key rate limiting instead of per-IP $key = 'license_rate_' . md5($licenseKey); ``` ### Caching Conflicts If a caching plugin modifies responses: ```php // Exclude license API from caching add_action('rest_api_init', function() { if (str_contains($_SERVER['REQUEST_URI'], '/wc-licensed-product/')) { // Disable page caching define('DONOTCACHEPAGE', true); // Set no-cache headers header('Cache-Control: no-store, no-cache, must-revalidate'); header('Pragma: no-cache'); } }); ```