WP Personalization
B2B content personalization for WordPress. Resolves a visitor's market segment via IP address lookup, then renders the matching personalization layout from a configured collection. Designed for block themes (Full Site Editing).
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/blake-c/wp-personalization/archive/refs/heads/main.zipThis plugin is still under development. It is not finished and is not ready to run on a production site.
B2B content personalization for WordPress. Resolves a visitor's market segment via IP address lookup, then renders the matching personalization layout from a configured collection. Designed for block themes (Full Site Editing).
How It Works
- A Collection block is placed on a page. It holds a set of Personalizations — one per market segment.
- On page load, a skeleton loader is shown immediately (cacheable HTML).
- The browser posts to the REST API. If the visitor has a cached segment slug in
localStoragefrom a previous visit (or just picked one in the fallback modal), that slug is sent on the request and the server skips IP lookup entirely. Otherwise the server resolves the visitor's IP to an organization name, matches it against the segment keyword list, and returns the matching personalization's HTML. - The skeleton is replaced with the personalized content. If the timeout fires first, the default segment is shown instead.
- The resolved segment slug is stored in
localStorage(TTL-controlled) so subsequent visits short-circuit IP resolution. Stale cache entries (e.g. when an editor changes which personalizations belong to a collection) are detected via the response'sis_defaultflag and dropped automatically.
Requirements
- WordPress 6.4+
- PHP 8.1+
- A block theme (FSE)
- An ipinfo.io or ip-api.com API key (free tiers available)
Installation
- Upload the
wp-personalizationfolder towp-content/plugins/. - Activate the plugin in Plugins > Installed Plugins.
- Go to WP Personalization > Settings and configure your IP provider and API key.
- Build the JavaScript assets (see Build).
Build
Dependencies and build commands run from the plugin root.
pnpm install
pnpm run build
| Command | Description |
|---|---|
pnpm run build |
Production build — compiles block JS/SCSS to build/ |
pnpm run start |
Watch mode for development |
pnpm run lint:js |
ESLint |
pnpm run lint:style |
Stylelint |
pnpm run lint:php |
PHPCS (requires Composer install at wp-content/) |
pnpm run lint:php:fix |
PHPCBF auto-fix |
Configuration
All settings live under WP Personalization > Settings.
| Setting | Description |
|---|---|
| IP Provider | ipinfo.io, ip-api.com, or none (disables resolution) |
| API Key | Provider API key (encrypted at rest — see API Key Storage) |
| Resolve Timeout | Milliseconds before the default segment is shown (default: 3000) |
| Cache TTL | Seconds to cache an IP-to-org lookup transient (default: 86400) |
| Consent Provider | builtin, onetrust, or none |
| Proxy Header | Which header carries the real client IP behind a proxy |
API Key Storage
API keys are encrypted with AES-256-GCM before being written to wp_options. The encryption key is derived via HKDF-SHA256 from one of:
- The
WP_PERSONALIZATION_KEYconstant if defined inwp-config.php(recommended for production — key management is decoupled from WordPress salts). - WordPress
AUTH_KEY/SECURE_AUTH_KEY/AUTH_SALTas a fallback.
// wp-config.php — recommended for production
define( 'WP_PERSONALIZATION_KEY', 'a long random string, 32+ bytes' );
Key rotation: rotating WordPress salts (or WP_PERSONALIZATION_KEY) invalidates any stored ciphertexts — you'll need to re-enter API keys in WP Personalization > Settings afterwards.
Threat model: encryption protects stored secrets against DB-only compromise (SQL injection, leaked backups). An attacker with filesystem access to wp-config.php can still read the key material. Never commit real keys to version control.
The settings UI never echoes stored keys back to the browser — only a masked placeholder is shown. To overwrite a key, type a new one; to remove it, tick Clear stored key and save.
Segment Rules
Custom keyword-to-segment mappings are managed under WP Personalization > Segment Rules. Rules are evaluated before the built-in keyword list. A lower priority number runs first.
Data Model
| Post Type | Slug | Description |
|---|---|---|
| Personalization | wpp_personalization |
A single segment-specific layout (block content) |
| Collection | wpp_collection |
A group of personalizations with a designated default |
| Taxonomy | Slug | Description |
|---|---|---|
| Segment | wpp_segment |
Market vertical assigned to a personalization |
REST API
Both endpoints are public (no nonce or user context required) and protected by the same per-IP rate limiter (10 requests per 60 seconds, keyed by the admin-configured client-IP source).
| Method | Endpoint | Description |
|---|---|---|
POST |
/wp-json/wp-personalization/v1/resolve |
Resolve segment and return personalization HTML |
GET |
/wp-json/wp-personalization/v1/segments |
List available segments for a collection |
POST /resolve request body:
| Field | Type | Required | Description |
|---|---|---|---|
collection_id |
integer | yes | Published wpp_collection post ID |
segment |
string | no | Client-supplied segment slug. When provided, the server skips IP resolution and renders the personalization that matches the slug. Used by the fallback modal selection and the localStorage cache. If the slug doesn't match any personalization in the collection, the response falls back to the default and is_default: true. |
POST /resolve response:
{ "segment": "banking", "html": "...", "is_default": false }
When IP resolution fails or no segment matches, the response is { "segment": "", "html": "...default...", "is_default": true }.
IP Providers
| Provider | Free Tier | Notes |
|---|---|---|
| ipinfo.io | 50,000 req/month | HTTPS; returns ASN + org name |
| ip-api.com | 45 req/min (non-commercial) | HTTP on free tier — blocked in production (wp_get_environment_type() === 'production'); paid key enables HTTPS via pro.ip-api.com |
ipinfo.io is the recommended provider because it serves HTTPS on the free tier. The ip-api.com free tier is retained for local/dev/staging use only — in production the plugin refuses to call it and logs a WP_Error so that on-path attackers cannot forge the org value and steer visitors to attacker-chosen segments.
An admin notice appears when ipinfo.io usage reaches 80% of the free-tier limit, and when ip-api.com is selected without a paid key.
GDPR / Consent
IP processing and localStorage writes only happen after consent is granted.
- OneTrust: listens for
OnetrustActiveGroupsincluding the configured group ID (defaultC0002). - Built-in: renders a fixed bottom banner; sets a
wpp_consent=acceptedcookie on accept. - None: skips consent gating entirely.
Data retention notes
- The visitor's IP is processed server-side to resolve an organisation name; it is never returned to the client and never written to post data.
- The IP lookup result (org name) is cached as a WordPress transient. The cache key is an HMAC-SHA256 derived from the IP and
wp_salt('auth')— without access to the WordPress salts, an attacker who reads the options table cannot enumerate the IPv4 keyspace to recover which IPs were seen. - The cached value itself (org name, e.g. "Wells Fargo") is still identifying data about the network a visitor came from. Treat this cache entry like other GDPR-relevant data in your retention policy, and purge it when required. Rotating WordPress auth salts invalidates all existing cache keys and effectively clears the mapping.
- Only the non-PII segment slug (e.g.
banking) is ever written tolocalStorageon the client.
Changelog
See CHANGELOG.md.