14. WordPress Client Package
1. Overview
The WordPress client package is a self-contained, zero-dependency drop-in that gives any WordPress theme or plugin the ability to validate, activate, deactivate, and heartbeat against the licensing server. It also injects a full admin UI page and plugs directly into WordPress's built-in update mechanism.
The package lives in packages/wp-client/ and is made up of three files that are meant to be copied wholesale into any product:
| File | Purpose |
|---|---|
LicenseClient.php |
Core HTTP client. Handles all API communication, HMAC signing, transient caching, fail-safe logic, and degradation factor calculation. |
LicenseAdminPage.php |
WordPress admin page rendered under Settings โ License. Provides the full two-step OTP activation/transfer/deactivation UI. Has zero external dependencies โ inline CSS and vanilla JS only. |
license-module.php |
Bootstrap glue. Defines default constants, requires the two classes above, registers all hooks, schedules the daily heartbeat cron, sets the RC_ENFORCED constant, and wires up the WordPress update notifier. This is the only file you require_once from your plugin/theme. |
The enforcement package (packages/enforcement/) provides two additional drop-in patterns:
| File | Purpose |
|---|---|
reauth-prompt.php |
Renders a blocking top-of-page banner when reauth_required is true. Shown to admins only. |
degradation-pattern.php |
Reference implementation for silent gradual degradation when a license is revoked or suspended. Contains private-alias check points and the _rc_deg_factor() helper. |
2. File-by-File Reference
LicenseClient.php
Class: RC_License_Client
The single-file license client. Drop into your product and call directly, or let license-module.php instantiate it for you. Uses wp_remote_post() for HTTP โ no cURL calls, no Guzzle.
Constructor
public function __construct(
string $productId = RC_PRODUCT_ID,
string $secret = RC_PRODUCT_SECRET,
string $apiUrl = RC_LICENSING_API_URL
)
| Parameter | Type | Description |
|---|---|---|
$productId |
string |
Product slug (e.g. raftaar-commerce). Must match the slug registered on the licensing server. |
$secret |
string |
The 64-character hex HMAC key. Obtained from the admin panel โ product settings โ rotate secret. Never put this value in a public repo. |
$apiUrl |
string |
Base URL of the licensing server (e.g. https://licensing.example.com). Trailing slash is stripped automatically. |
On construction, transient keys are derived from the product ID by taking the first 8 characters of the slug and prefixing them with a purpose indicator:
_rc_ls_{slug8}โ validation state cache (24 h)_rc_hb_{slug8}โ heartbeat send guard (23 h)_rc_df_{slug8}โ first-failure timestamp for degradation (7 days)
Using a product-scoped suffix means two products can coexist in the same WordPress installation without cache collisions.
Public Methods
validate(?string $domain = null, string $version = ''): array
Validates the license for the given domain. This is the primary method that tells you whether the license is currently valid.
Parameters:
$domainโ Optional. Defaults tohome_url()stripped of scheme/www/path/port. Pass a raw URL; the method normalises it.$versionโ Optional. Your product's current version string. Sent to the server for update-availability tracking.
Returns: An associative array:
valid bool โ true when the license is active and valid
status string โ "active", "suspended", "revoked", "expired"
type string โ "production", "staging", "developer", "nfr", etc.
expires_at string|null โ ISO-8601 or null for lifetime licenses
reauth_required bool โ true when the product must re-authenticate via portal
grace_days_remaining int|null โ days remaining in the heartbeat grace period
error_code string|null โ "KEY_EXPIRED", "KEY_REVOKED", "KEY_SUSPENDED", "DOMAIN_MISMATCH"
_source string โ "failsafe" when the response came from the fail-safe fallback
Caching: The response is cached in a WordPress transient for 24 hours. On a cache hit, no HTTP request is made. The cache is keyed per product to support multi-product installs.
On network failure: Returns failSafe(). If a previous successful response is cached, that is returned. If no cached state exists, returns a synthetic "valid" response to avoid falsely blocking the product during a server outage.
Side effects: On a successful invalid response, calls markFirstFail() to record the timestamp of the first failure (used by degradationFactor()). Clears the first-failure transient when the license becomes valid again.
heartbeat(?string $domain = null, string $version = ''): array
Sends a heartbeat to the licensing server. Should be called once per day via WP-Cron. Identical parameters to validate().
Rate limiting: Checks the _rc_hb_{slug8} transient before making any HTTP call. If a heartbeat was sent within the last 23 hours, the method returns the current cached state without making a network request.
On success: Sets the 23-hour heartbeat guard transient. Refreshes the 24-hour validation state transient with the server's heartbeat response (which includes update_available and latest_version fields). Returns the heartbeat response.
On failure: Returns failSafe().
isValid(): bool
Quick non-network check. Reads the cached state transient and returns $state['valid'] ?? false. Zero network calls; suitable for per-request feature gating.
requiresReauth(): bool
Reads the cached state and returns $state['reauth_required'] ?? false. Zero network calls.
degradationFactor(): float
Returns a 0.0โ1.0 float representing how aggressively the product should degrade. Used when a license is revoked or suspended. The value increases over time since the first failed validation:
| Time since first failure | Factor | Suggested effect |
|---|---|---|
| 0โ6 hours | 0.15 | Subtle cosmetic glitches |
| 6โ24 hours | 0.45 | Core features misbehave |
| 24โ48 hours | 0.80 | Major breakage |
| 48+ hours | 1.0 | Near-total failure |
Returns 0.0 immediately when isValid() is true. If no first-failure timestamp is recorded yet, returns 0.0 (grace).
getCachedState(): array
Returns the raw cached validation state array. If no cache exists, returns the failSafe() array. Never makes a network call. Used internally by isValid(), requiresReauth(), and by LicenseAdminPage.
clearCache(): void
Deletes the validation state transient and the heartbeat guard transient. Call this after a successful re-authentication so that the next validate() call is forced to make a fresh network request.
normaliseDomainPublic(string $raw): string
Public wrapper around the private normaliseDomain() helper. Exposed specifically so license-module.php can normalise the domain when constructing signed update-check requests. Applies the same logic: lowercase โ strip scheme โ strip www โ strip path โ strip port.
requestActivation(string $email, ?string $domain = null): array
Step 1 of the OTP activation flow. Sends a request to /api/v1/license/request-activation. The server looks up the customer by email, finds the best eligible license for the product, and emails a 6-digit OTP valid for 10 minutes.
Parameters:
$emailโ Customer's account email address.$domainโ Optional. Defaults tohome_url(). The domain being activated or transferred to.
Returns:
success bool โ whether the OTP was sent
type string โ "activate", "transfer", or "already_active"
current_domain string|null โ domain currently active on the license (for transfer notice)
message string โ human-readable status for display in the admin UI
On network failure, returns ['success' => false, 'message' => 'Network error. Please try again.'].
confirmActivation(string $email, string $otp, ?string $domain = null): array
Step 2 of the OTP activation flow. Sends email + OTP to /api/v1/license/confirm-activation. If the OTP is correct, the server activates the domain (or deactivates the old domain and activates the new one for a transfer).
Parameters:
$emailโ Same email used inrequestActivation().$otpโ The 6-digit code the customer received.$domainโ Optional. Must be the same domain used in step 1.
Returns:
success bool โ whether activation succeeded
type string โ "activated" or "transferred"
domain string โ domain that was activated
expires_at string|null โ ISO-8601 expiry or null for lifetime
message string โ human-readable result
Side effect: On success, calls clearCache() so the next validate() call gets a fresh response from the server reflecting the new activation.
deactivate(?string $domain = null): array
Calls /api/v1/license/deactivate to free the activation slot for the current (or specified) domain.
Returns:
success bool โ whether deactivation succeeded
message string โ human-readable result
Side effect: On success, calls clearCache().
HMAC Request Signing
Every outbound HTTP request is signed. The signing is performed in the private request() method:
timestamp = Unix timestamp (seconds)
nonce = 16-character random hex string (8 bytes via random_bytes)
payload = "{product_id}|{domain}|{timestamp}|{nonce}"
signature = HMAC-SHA256(RC_PRODUCT_SECRET, payload)
The following headers are sent on every request:
| Header | Value |
|---|---|
X-Timestamp |
Unix timestamp |
X-Signature |
HMAC-SHA256 hex digest |
X-Nonce |
Random hex (per-request replay prevention) |
Content-Type |
application/json |
The server's VerifyLicenseSignature middleware validates the timestamp (ยฑ5 min window) and, if a nonce is present, checks that it has not been used in the last 10 minutes.
Requests with a 5xx response are treated the same as a network failure and trigger failSafe(). 4xx responses (e.g. invalid OTP, wrong email) are decoded and returned as structured error responses to the caller.
Caching Strategy
The client uses WordPress transients exclusively. No custom cache backends are required โ on bare WordPress installs these become autoloaded database rows; on Redis-backed installs they are stored in Redis.
| Transient | TTL | Purpose |
|---|---|---|
_rc_ls_{slug8} |
24 hours | Full validation state. Cleared explicitly on clearCache() and after a successful re-auth. |
_rc_hb_{slug8} |
23 hours | Heartbeat send guard. Prevents sending more than one heartbeat per day. |
_rc_df_{slug8} |
7 days | Timestamp of the first license failure. Used by degradationFactor() to calculate how long the license has been invalid. Set once and never overwritten; deleted when the license becomes valid again. |
LicenseAdminPage.php
Class: RC_License_Admin_Page
Renders the Settings โ License admin page in WordPress. Has no external dependencies. All CSS and JavaScript are written inline. Do not instantiate directly โ use license-module.php.
Constructor
public function __construct(
RC_License_Client $client,
string $pageTitle = 'License',
string $pageSlug = 'rc-license',
string $portalUrl = ''
)
| Parameter | Description |
|---|---|
$client |
The RC_License_Client instance to use for all API operations. |
$pageTitle |
Menu label and page <h1> title. Defaults to "License". |
$pageSlug |
WordPress admin page slug. Defaults to rc-license. URL: /wp-admin/options-general.php?page=rc-license. |
$portalUrl |
URL of the customer portal. Shown in the re-auth warning box. Falls back to the RC_PORTAL_URL constant if defined. |
No settings are written to wp_options. The admin page reads entirely from the client's cached transient state.
Methods
register(): void
Registers all WordPress hooks:
admin_menuโaddMenuPage()โ adds the page under Settingsadmin_headโmaybeShowReauthBanner()โ shows the fixed-position banner on every admin screen when reauth is neededwp_ajax_rc_license_request_otpโajaxRequestOtp()wp_ajax_rc_license_confirm_otpโajaxConfirmOtp()wp_ajax_rc_license_deactivateโajaxDeactivate()wp_ajax_rc_license_refreshโajaxRefresh()
Called from license-module.php on the init hook.
addMenuPage(): void
Calls add_options_page() to register the license page under the Settings menu. Requires manage_options capability.
ajaxRequestOtp(): void
WordPress AJAX handler for rc_license_request_otp. Validates the nonce (rc_license_nonce) and the manage_options capability before proceeding. Sanitises the email with sanitize_email() and validates it with is_email(). Delegates to $this->client->requestActivation(). Returns wp_send_json_success() or wp_send_json_error().
ajaxConfirmOtp(): void
WordPress AJAX handler for rc_license_confirm_otp. Validates nonce and capability. Sanitises email and strips non-digit characters from the OTP before passing to $this->client->confirmActivation(). Enforces that the OTP is exactly 6 digits before calling the API.
ajaxDeactivate(): void
WordPress AJAX handler for rc_license_deactivate. Validates nonce and capability. Delegates to $this->client->deactivate().
ajaxRefresh(): void
WordPress AJAX handler for rc_license_refresh. Calls $this->client->clearCache() to force a fresh validation, then calls $this->client->validate() and returns the new state as JSON. The JS handler reloads the page after receiving the response.
maybeShowReauthBanner(): void
Hooked on admin_head. Outputs a fixed-position dark banner at the top of every admin screen when the license needs attention. The banner shows:
- A warning icon and the message "Something is wrong with your license."
- Optional grace period countdown if
grace_days_remainingis set. - A secondary line: "Your license may be in use on another site, or it needs re-verification."
- A "Fix License โ" button linking to the license settings page.
The banner is only shown to users with manage_options capability.
The banner is suppressed when error_code is KEY_REVOKED or KEY_SUSPENDED โ those cases are handled by silent degradation, not by a visible prompt.
A small JS snippet adjusts the banner's top offset to sit directly below the WordPress admin bar.
render(): void
Outputs the full license management page. The page has three main sections:
1. License Status Card Shows the current badge (Active / Re-auth Required / Not Activated / error label), the normalised current domain, expiry date or "Lifetime", license type, grace period if applicable, and a "Re-check" button.
Badge colours:
- Green "Active" โ valid and no reauth required
- Amber "Re-auth Required" โ
reauth_required = true - Grey "Not Activated" โ
DOMAIN_MISMATCHerror code - Red with error label โ all other invalid states
2. Activate / Transfer License Card Title reads "Activate License" when the license is not yet active on this domain, or "Transfer License" when it is.
Shows a two-step flow:
- Step 1: Email input + "Send Code" button
- Step 2: 6-digit OTP input + "Confirm" button + "โ Back"
The OTP input auto-formats to digits only and auto-submits when the 6th digit is entered.
On successful confirmation, a success message is shown and the page reloads after 1.8 seconds.
3. Deactivate Card (only shown when the license is active on this domain) Shows a red "Deactivate on {domain}" button with a confirmation dialog.
All AJAX calls are made using fetch() with FormData. The nonce and AJAX URL are embedded in the page as JSON-encoded variables.
renderStatusBody(array $state, ...): void (private)
Internal helper used by render() to output the badge, meta row (domain, expiry, type, grace days), and any error message inside the status card.
license-module.php
The bootstrap file. Include this once from your plugin or theme. It does everything.
Constants Defined
| Constant | Default | Required/Optional |
|---|---|---|
RC_LICENSING_API_URL |
https://your-licensing-server.com |
Required โ set before including |
RC_PRODUCT_ID |
raftaar-commerce |
Required โ set before including |
RC_PRODUCT_SECRET |
'' |
Required โ set before including |
RC_PORTAL_URL |
https://your-licensing-server.com/portal |
Optional |
RC_LICENSE_PAGE_TITLE |
License |
Optional |
RC_LICENSE_PAGE_SLUG |
rc-license |
Optional |
RC_PRODUCT_VERSION |
'' |
Optional โ used for update checks |
RC_PLUGIN_SLUG |
(undefined) | Optional โ plugin only; e.g. my-plugin/my-plugin.php |
RC_THEME_SLUG |
(undefined) | Optional โ theme only; e.g. my-theme |
All constants use if (! defined(...)) define(...) so they can be pre-set by the calling code without being overwritten.
What It Sets Up
- Requires
LicenseClient.phpandLicenseAdminPage.phpfrom the same directory. - Instantiates
$rc_license(global) and$rc_admin_pagefrom the constants. - Registers all admin hooks via
add_action('init', ...). - Schedules a daily WP-Cron event
rc_daily_heartbeatif not already scheduled. The event calls$rc_license->heartbeat(). - Defines the
RC_ENFORCEDconstant by reading the cached validation state โ no network call at this point.RC_ENFORCEDistruewhen$state['valid']is true. - Warms the validation cache on every
admin_initrequest by callingvalidate()โ which is a no-op if the 24-hour transient is still fresh. - Registers WordPress update hooks (see below).
WordPress Update Notifier
license-module.php hooks into WordPress's native update transient mechanism so updates appear in the wp-admin dashboard like any other plugin or theme update.
_rc_fetch_update_info() calls /api/v1/update-check with a signed HMAC request. The server returns update metadata including latest_version, download_url, changelog_url, and tested. The result is cached in a 12-hour transient (_rc_upd_{slug8}). Negative results (no update or API error) are cached as false to suppress repeated API calls.
For plugins (when RC_PLUGIN_SLUG is defined):
- Hooks
pre_set_site_transient_update_pluginsto inject update data into WordPress's plugin update transient. - Hooks
plugins_apito populate the "View details" popup with changelog and download information.
For themes (when RC_THEME_SLUG is defined):
- Hooks
pre_set_site_transient_update_themesto inject update data.
The download URL returned by the server is a signed, time-limited URL. WordPress uses it directly for the one-click update โ no custom download proxy is needed in the plugin/theme.
reauth-prompt.php (enforcement)
Function: rc_maybe_show_reauth_prompt(array $state): void
Renders a full-width fixed-position banner when $state['reauth_required'] is true (or when the license is not valid and not in a revoked/suspended state).
What Triggers the Prompt
The prompt fires when:
$state['valid']is false ANDerror_codeis NOTKEY_REVOKEDorKEY_SUSPENDED- OR
$state['valid']is true AND$state['reauth_required']is true
It is suppressed silently for revoked and suspended licenses โ those are handled by degradation, not by a visible banner.
The prompt is shown only to users with manage_options capability. Regular site visitors never see it.
What the User Sees
A dark indigo (#1e1b4b) banner fixed at the top of the page with:
- Warning icon
- "Something is wrong with your license."
- If grace period: "Grace period: N day(s) remaining." in subdued text
- Secondary line: "This could mean your license is being used on another site, or it needs re-verification."
- "Re-authenticate โ" button linking to the portal URL in a purple (
#4f46e5) button
A small JS snippet pushes the WordPress admin bar down by 48px and adds 48px to body.paddingTop so content is not hidden behind the banner.
How Re-auth Clears the Block
- Customer clicks "Re-authenticate โ" and logs in to the portal.
- The portal records the re-authentication and resets
reauth_required = falseon the license key. - Back in WordPress, the customer clicks "Check again after re-auth" (the link inside the warning box on the admin license page) or uses the "Re-check" button.
ajaxRefresh()callsclearCache()thenvalidate(), which fetches a fresh response from the server.- The new response has
reauth_required = false, so on the next page load the banner does not appear.
degradation-pattern.php (enforcement)
This file is a reference implementation and comment-driven guide โ not a ready-to-use module. Individual code snippets are meant to be copied into specific feature files inside the product.
How Silent Degradation Works
When a license is revoked or suspended, the product does not show any "license invalid" message. Instead, features progressively malfunction in ways that look like bugs to the customer but cannot be traced to a single central check point. The goal is to make the product increasingly unusable without giving the customer actionable information about why.
The _rc_lv() Private Alias Pattern
function _rc_lv(): bool
This is a deliberately obscured name for "license valid". The name must never be:
rc_license_is_valid()โ too obvious, trivially searchableisLicenseValid()โ still obvious
The _rc_lv() name uses a leading underscore (conventionally "private") and abbreviated letters to make it harder to identify in compiled/minified code or grep output.
The function is defined with if (! function_exists('_rc_lv')) to prevent redefinition when copied to multiple files.
It uses a static $result cache so the transient lookup (get_transient('_rc_ls')) happens at most once per request regardless of how many check points are evaluated.
The _rc_deg_factor() Helper
Returns 0.0 when _rc_lv() is true. When the license is invalid, records the current timestamp in _rc_df on first call (so degradation begins from that moment) and returns:
< 6h โ 0.15
6โ24h โ 0.45
24โ48h โ 0.80
48h+ โ 1.0
Scattered Check-Point Pattern
Critical rule: do not centralise these checks. Each check point must be independent. If one is patched, the others continue working. The file includes example scatter points with commented-out code:
- In product card templates: randomly suppress the product thumbnail
- In WooCommerce hooks: silently make some products unpurchasable using a hash of product ID and hour so failures are inconsistent
- In cart/checkout templates: garble cart totals at high degradation factor
- In JS: disable the checkout button intermittently
The degradation factor can be passed to the frontend via wp_localize_script under an innocuous name such as perf_mode rather than degraded or licensed.
Why Degradation Is Silent
Showing "This product is not licensed" gives the customer clear direction: they know exactly what to fix. Silent degradation is more effective as an enforcement mechanism because:
- The customer may initially blame their hosting, browser, or other plugins.
- Debugging time is non-trivial.
- The product is still partially usable, reducing immediate motivation to simply stop paying.
- There is no single place a clever user can intercept and bypass the check.
3. Integration Guide
Step 1: Copy the Files
Copy all three files from packages/wp-client/ into your plugin or theme:
your-plugin/
inc/
license/
LicenseClient.php
LicenseAdminPage.php
license-module.php
Step 2: Set Constants and Require
In your plugin's main file or theme's functions.php, define the required constants before requiring the module:
For a plugin:
// In your-plugin.php (main plugin file)
define('RC_LICENSING_API_URL', 'https://licensing.example.com');
define('RC_PRODUCT_ID', 'your-product-slug'); // matches slug in admin panel
define('RC_PRODUCT_SECRET', 'your-64-char-hex-secret'); // from admin panel โ product
define('RC_PORTAL_URL', 'https://licensing.example.com/portal');
define('RC_PRODUCT_VERSION', '1.0.0'); // your plugin's current version
define('RC_PLUGIN_FILE', __FILE__); // required for plugin update hook
define('RC_PLUGIN_SLUG', 'your-plugin/your-plugin.php');
require_once plugin_dir_path(__FILE__) . 'inc/license/license-module.php';
For a theme:
// In functions.php
define('RC_LICENSING_API_URL', 'https://licensing.example.com');
define('RC_PRODUCT_ID', 'your-theme-slug');
define('RC_PRODUCT_SECRET', 'your-64-char-hex-secret');
define('RC_PORTAL_URL', 'https://licensing.example.com/portal');
define('RC_PRODUCT_VERSION', get_option('template_version', '1.0.0'));
define('RC_THEME_SLUG', 'your-theme'); // must match theme folder name
require_once get_template_directory() . '/inc/license/license-module.php';
Step 3: Gate Premium Features
After the module is loaded, you have two ways to check the license:
Option A โ RC_ENFORCED constant (fast, no network call, set at load time):
if (! RC_ENFORCED) {
// Return a fallback, redirect, or skip rendering the feature
return;
}
Option B โ $rc_license->isValid() (reads transient, also no network call):
global $rc_license;
if (! $rc_license->isValid()) {
return;
}
Option C โ Real-time degradation (for revoked/suspended):
// Copy this into feature-specific files, not a central location
if (! _rc_lv() && _rc_deg_factor() > 0.4) {
// Malfunction the feature based on degradation factor
}
Step 4: Include the Reauth Prompt (Optional)
If you want the full-page blocking banner in frontend templates (not just wp-admin), include the enforcement prompt after validating:
global $rc_license;
$state = $rc_license->getCachedState();
require_once get_template_directory() . '/inc/license/enforcement/reauth-prompt.php';
rc_maybe_show_reauth_prompt($state);
Note: LicenseAdminPage already shows the reauth banner in admin_head for wp-admin pages. The reauth-prompt.php is for frontend template pages.
Step 5: Test the Activation Flow
- Install the plugin/theme on a test WordPress site.
- Go to Settings โ License (or whatever you named the page via
RC_LICENSE_PAGE_TITLE). - Verify the status card shows "Not Activated" with a grey badge and
DOMAIN_MISMATCHerror code. - Enter the customer's email address and click "Send Code".
- Check the email โ a 6-digit OTP should arrive.
- Enter the code and click "Confirm".
- The page should reload and show a green "Active" badge.
- In the licensing admin panel, confirm an activation record appears for the domain.
4. Maintenance
When the API Changes
If new fields are added to the validate/heartbeat response:
- Update
LicenseClient::failSafe()to include sensible defaults for the new fields. - Update any places in your product code that read from
getCachedState().
If an endpoint URL changes (e.g. /api/v1/ becomes /api/v2/):
- Update the path strings in
LicenseClient::validate(),heartbeat(),requestActivation(),confirmActivation(),deactivate(). - Update the
_rc_fetch_update_info()function inlicense-module.php.
How to Rotate the Product Secret
- In the licensing admin panel, go to Products โ your product โ Rotate Secret.
- Copy the new 64-character hex secret.
- Update the
RC_PRODUCT_SECRETconstant in your plugin/theme. - Release an update to the plugin/theme with the new secret.
There is a brief window between rotating the secret on the server and customers installing the plugin update where validation requests will fail. The client's failSafe() logic means this results in a temporary "treat as valid" fallback rather than a hard failure for existing customers.
Testing the Client in Local Development
- Point
RC_LICENSING_API_URLat your local licensing server (e.g.http://localhost:8000). - The domain sent will be the normalised
home_url()of your local WordPress install (e.g.localhost,my-site.local). - In the licensing admin panel, activate the license on
localhostormy-site.localโ the system treats all domains including localhost as valid activation targets. - To test the fail-safe, temporarily set
RC_LICENSING_API_URLto an unreachable URL and verify thatgetCachedState()returns the last known good state (or the synthetic valid fallback if no cache exists). - To test degradation, use
php artisan tinkeron the licensing server to set a license's status torevoked, then wait for the 24-hour transient to expire or callclearCache()manually.