- Add complete API endpoints reference with request/response formats - Add recursive key sorting for nested objects in signatures - Add comprehensive error codes table with HTTP status codes - Add rate limiting implementation with configurable limits - Add complete WordPress plugin example with all handlers - Add security sections: HTTPS, input sanitization, caching conflicts - Update PHP version requirement to 8.3 for consistency - Expand troubleshooting section with more scenarios Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
26 KiB
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:
- REST API endpoints for license operations
- Response signing using HMAC-SHA256 for tamper protection
- Rate limiting to prevent abuse
- 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:
// wp-config.php
define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
Generate a secure secret:
# 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)
// 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:
{
"license_key": "ABCD-1234-EFGH-5678",
"domain": "example.com"
}
Success Response (200):
{
"valid": true,
"license": {
"product_id": 123,
"expires_at": "2027-01-21",
"version_id": null
}
}
Error Response (403):
{
"valid": false,
"error": "license_expired",
"message": "This license has expired."
}
POST /status
Get detailed license status information.
Request:
{
"license_key": "ABCD-1234-EFGH-5678"
}
Success Response (200):
{
"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:
{
"license_key": "ABCD-1234-EFGH-5678",
"domain": "newdomain.com"
}
Success Response (200):
{
"success": true,
"message": "License activated successfully."
}
Error Response (403):
{
"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:
{
"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:
/**
* 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);
}
Recursive Key Sorting
IMPORTANT: Response data must have keys sorted recursively for consistent signatures:
/**
* 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:
/**
* 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
signature = HMAC-SHA256(
key = derive_signing_key(license_key, server_secret),
message = timestamp + ":" + canonical_json(response_body)
)
Where:
derive_signing_keyuses HKDF-like derivationcanonical_jsonsorts keys recursively, usesJSON_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
/**
* Plugin Name: WC Licensed Product API
* Description: License validation API with response signing
* Version: 1.0.0
* Requires PHP: 8.3
*/
declare(strict_types=1);
namespace WcLicensedProduct;
/**
* License API with response signing and rate limiting.
*/
final class LicenseApi
{
private const API_NAMESPACE = 'wc-licensed-product/v1';
private string $serverSecret;
private int $rateLimit;
private int $rateWindow;
public function __construct()
{
$this->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(),
]);
}
// =========================================================================
// 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
{
$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 {
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
{
$prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true);
return hash_hmac('sha256', $prk . "\x01", $this->serverSecret);
}
// =========================================================================
// 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
}
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
// 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
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
# 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:
// 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:
- Deploy new secret to server
- Update client configurations
- Old signatures become invalid immediately
For zero-downtime rotation, implement versioned secrets:
// 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:
// 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:
// 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:
// 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
ksortand flags) - Nested objects not sorted consistently
Debugging
Enable detailed logging:
// 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);
// Client-side: use a PSR-3 logger
$client = new SecureLicenseClient(
// ...
logger: new YourDebugLogger(),
);
Rate Limit Issues
If legitimate users hit rate limits:
// 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:
// 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');
}
});