2026-01-23 16:45:59 +01:00
# Server-Side Implementation Guide
2026-01-22 16:16:59 +01:00
2026-01-23 16:45:59 +01:00
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.
2026-01-22 16:16:59 +01:00
## Overview
2026-01-23 16:45:59 +01:00
The license server provides:
2026-01-22 16:16:59 +01:00
2026-01-23 16:45:59 +01:00
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
2026-01-22 16:16:59 +01:00
2026-01-23 16:45:59 +01:00
### Security Model
The signature system prevents attackers from:
2026-01-22 16:16:59 +01:00
- Faking valid license responses
2026-01-23 16:45:59 +01:00
- Replaying old responses (timestamp validation)
- Tampering with response data in transit
2026-01-22 16:16:59 +01:00
## Requirements
2026-01-23 16:45:59 +01:00
- PHP 8.3 or higher (to match client requirements)
- WordPress 6.0+ with WooCommerce
2026-01-22 16:16:59 +01:00
- 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
2026-01-23 16:45:59 +01:00
// wp-config.php
2026-01-22 16:16:59 +01:00
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!
2026-01-23 16:45:59 +01:00
### 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."
}
```
## 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 |
| `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
2026-01-22 16:16:59 +01:00
### Key Derivation
Each license key gets a unique signing key derived from the server secret:
```php
/**
* Derive a unique signing key for a license.
*
* @param string $licenseKey The license key
* @param string $serverSecret The server's master secret
* @return string The derived key (hex encoded)
*/
function derive_signing_key(string $licenseKey, string $serverSecret): string
{
// HKDF-like key derivation
$prk = hash_hmac('sha256', $licenseKey, $serverSecret, true);
return hash_hmac('sha256', $prk . "\x01", $serverSecret);
}
```
2026-01-23 16:45:59 +01:00
### 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;
}
```
2026-01-22 16:16:59 +01:00
### 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);
2026-01-23 16:45:59 +01:00
// Sort keys recursively for consistent ordering
$sortedData = sort_keys_recursive($responseData);
2026-01-22 16:16:59 +01:00
// Build signature payload
2026-01-23 16:45:59 +01:00
$jsonBody = json_encode($sortedData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
2026-01-22 16:16:59 +01:00
$payload = $timestamp . ':' . $jsonBody;
// Generate HMAC signature
$signature = hash_hmac('sha256', $payload, $signingKey);
return [
'X-License-Signature' => $signature,
'X-License-Timestamp' => (string) $timestamp,
];
}
```
2026-01-23 16:45:59 +01:00
### Signature Algorithm
2026-01-22 16:16:59 +01:00
2026-01-23 16:45:59 +01:00
```text
signature = HMAC-SHA256(
key = derive_signing_key(license_key, server_secret),
message = timestamp + ":" + canonical_json(response_body)
)
```
2026-01-22 16:16:59 +01:00
2026-01-23 16:45:59 +01:00
Where:
2026-01-22 16:16:59 +01:00
2026-01-23 16:45:59 +01:00
- `derive_signing_key` uses HKDF-like derivation
- `canonical_json` sorts keys recursively, uses `JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE`
- Result is hex-encoded (64 characters)
2026-01-22 16:16:59 +01:00
2026-01-23 16:45:59 +01:00
### Response Headers
2026-01-22 16:16:59 +01:00
2026-01-23 16:45:59 +01:00
Every signed response includes:
2026-01-22 16:16:59 +01:00
2026-01-23 16:45:59 +01:00
| Header | Description | Example |
| ------ | ----------- | ------- |
| `X-License-Signature` | HMAC-SHA256 signature (hex) | `a1b2c3d4...` (64 chars) |
| `X-License-Timestamp` | Unix timestamp when signed | `1706000000` |
2026-01-22 16:16:59 +01:00
2026-01-23 16:45:59 +01:00
## Complete WordPress Plugin Implementation
2026-01-22 16:16:59 +01:00
```php
<?php
/**
2026-01-23 16:45:59 +01:00
* Plugin Name: WC Licensed Product API
* Description: License validation API with response signing
2026-01-22 16:16:59 +01:00
* Version: 1.0.0
2026-01-23 16:45:59 +01:00
* Requires PHP: 8.3
2026-01-22 16:16:59 +01:00
*/
2026-01-23 16:45:59 +01:00
declare(strict_types=1);
namespace WcLicensedProduct;
2026-01-22 16:16:59 +01:00
2026-01-23 16:45:59 +01:00
/**
* License API with response signing and rate limiting.
*/
final class LicenseApi
2026-01-22 16:16:59 +01:00
{
2026-01-23 16:45:59 +01:00
private const API_NAMESPACE = 'wc-licensed-product/v1';
2026-01-22 16:16:59 +01:00
private string $serverSecret;
2026-01-23 16:45:59 +01:00
private int $rateLimit;
private int $rateWindow;
2026-01-22 16:16:59 +01:00
public function __construct()
{
$this->serverSecret = defined('WC_LICENSE_SERVER_SECRET')
? WC_LICENSE_SERVER_SECRET
: '';
2026-01-23 16:45:59 +01:00
$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;
2026-01-22 16:16:59 +01:00
}
public function register(): void
{
2026-01-23 16:45:59 +01:00
add_action('rest_api_init', [$this, 'registerRoutes']);
2026-01-22 16:16:59 +01:00
add_filter('rest_post_dispatch', [$this, 'signResponse'], 10, 3);
}
2026-01-23 16:45:59 +01:00
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(),
]);
}
// =========================================================================
// 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
}
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
2026-01-22 16:16:59 +01:00
{
2026-01-23 16:45:59 +01:00
$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);
}
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 {
2026-01-22 16:16:59 +01:00
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;
}
2026-01-23 16:45:59 +01:00
private function shouldSign(\WP_REST_Request $request): bool
2026-01-22 16:16:59 +01:00
{
$route = $request->get_route();
2026-01-23 16:45:59 +01:00
return str_starts_with($route, '/' . self::API_NAMESPACE . '/');
2026-01-22 16:16:59 +01:00
}
private function createSignatureHeaders(array $data, string $licenseKey): array
{
$timestamp = time();
$signingKey = $this->deriveKey($licenseKey);
2026-01-23 16:45:59 +01:00
// Sort keys recursively for consistent ordering
$sortedData = $this->sortKeysRecursive($data);
2026-01-22 16:16:59 +01:00
$payload = $timestamp . ':' . json_encode(
2026-01-23 16:45:59 +01:00
$sortedData,
2026-01-22 16:16:59 +01:00
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
return [
'X-License-Signature' => hash_hmac('sha256', $payload, $signingKey),
'X-License-Timestamp' => (string) $timestamp,
];
}
2026-01-23 16:45:59 +01:00
private function sortKeysRecursive(array $data): array
{
ksort($data);
foreach ($data as $key => $value) {
if (is_array($value)) {
$data[$key] = $this->sortKeysRecursive($value);
}
}
return $data;
}
2026-01-22 16:16:59 +01:00
private function deriveKey(string $licenseKey): string
{
$prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true);
return hash_hmac('sha256', $prk . "\x01", $this->serverSecret);
}
2026-01-23 16:45:59 +01:00
// =========================================================================
// 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
}
2026-01-22 16:16:59 +01:00
2026-01-23 16:45:59 +01:00
/**
* 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'
2026-01-22 16:16:59 +01:00
2026-01-23 16:45:59 +01:00
return false; // Replace with actual implementation
}
2026-01-22 16:16:59 +01:00
2026-01-23 16:45:59 +01:00
private function isExpired(?array $license): bool
{
if ($license === null || empty($license['expires_at'])) {
return false; // Lifetime license
}
2026-01-22 16:16:59 +01:00
2026-01-23 16:45:59 +01:00
return strtotime($license['expires_at']) < time();
}
}
2026-01-22 16:16:59 +01:00
2026-01-23 16:45:59 +01:00
// Initialize plugin
add_action('plugins_loaded', function () {
(new LicenseApi())->register();
});
2026-01-22 16:16:59 +01:00
```
## 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;
2026-01-23 16:45:59 +01:00
use Magdev\WcLicensedProductClient\Security\SignatureException;
2026-01-22 16:16:59 +01:00
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!";
}
```
2026-01-23 16:45:59 +01:00
### 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
```
2026-01-22 16:16:59 +01:00
## 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
2026-01-23 16:45:59 +01:00
use Magdev\WcLicensedProductClient\Security\ResponseSignature;
2026-01-22 16:16:59 +01:00
$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');
```
2026-01-23 16:45:59 +01:00
### Sign Error Responses
2026-01-22 16:16:59 +01:00
Sign error responses too! Otherwise attackers could craft fake error messages:
```php
2026-01-23 16:45:59 +01:00
// 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
2026-01-22 16:16:59 +01:00
2026-01-23 16:45:59 +01:00
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');
}
2026-01-22 16:16:59 +01:00
```
## 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)
2026-01-23 16:45:59 +01:00
- JSON encoding differences (check recursive `ksort` and flags)
- Nested objects not sorted consistently
2026-01-22 16:16:59 +01:00
### Debugging
Enable detailed logging:
```php
2026-01-23 16:45:59 +01:00
// 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);
2026-01-22 16:16:59 +01:00
2026-01-23 16:45:59 +01:00
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
2026-01-22 16:16:59 +01:00
// Client-side: use a PSR-3 logger
$client = new SecureLicenseClient(
// ...
logger: new YourDebugLogger(),
);
```
2026-01-23 16:45:59 +01:00
### 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');
}
});
```