User Targeting

Add visitor conditions to XPAC Popups and XPAC Forms, so a popup opens or a form section appears only for visitors matching the device, page address, cookie, browser storage value or country you choose.

User Targeting answers one question, over and over: should this particular visitor see this?

On its own it shows nothing and has no screen of its own. What it does is add a User Conditions box to things other XPAC plugins already let you build — a popup's display rule, a section of a form — so that the popup opens, or the section appears, only for visitors who match. A welcome offer for phone users. A newsletter prompt only for people who have not already subscribed. A different call to action for visitors in Germany.

It does nothing on its own

User Targeting is a layer on top of other plugins, not a feature by itself. The conditions appear inside Popups (including its Inline type) and inside Conditional Logic for forms. With none of those active, nothing changes anywhere. The User Country condition needs one more thing again — see below.

Where to find it

There is no settings page and no menu entry. Open a popup under Popups, add or edit a display rule, and you will find User Conditions under the rule's own settings. In a form, the conditions appear in the field list of a Conditional Logic condition.

The conditions you can use

Five conditions are always available. A sixth, User Country, appears only when MaxMind is installed and configured — before that it is not in the list at all.

ConditionWhat it actually checksIf the signal is not there
DeviceWhether the visitor's browser identifies itself as an Android phone or an iPhone. Anything else counts as desktop.Always available, but see the warning below about what counts as "mobile".
Page URLThe address of the page the visitor is on right now. Every option under it is labelled "URL:" for that reason.Always available.
CookieWhether a cookie of a given name exists in the visitor's browser, and optionally what it contains.A cookie your server marked as private is invisible here and reads as "does not exist".
Local StorageWhether a named item exists in the browser's long-term storage, and optionally what it contains. Survives closing the browser.If the browser blocks storage, the check fails and the popup stays hidden.
Session StorageThe same, but for storage that is wiped when the visitor closes the tab.As above.
User CountryA two-letter country code worked out from the visitor's IP address.Without MaxMind set up, the condition is not offered. If a lookup fails, see MaxMind.

Each condition can be reversed — "is" and "is not", "contains" and "doesn't contain", "exists" and "does not exist" — and cookie and storage values can also be matched by start, end, wildcard or regular expression.

There is no “logged in”, “role”, “first visit” or “date range” condition

Those are common in targeting tools and this plugin does not have them. The nearest equivalent to "returning visitor" is a Cookie or Local Storage condition — but only for something that is already being stored by your site or another plugin. User Targeting never writes a cookie or a storage item itself, it only reads what is already there. If nothing sets a "seen it" marker, there is nothing to test.

Building a rule

Open the popup (or the form's conditional section) you want to target and go to the rule you are already using to decide where it appears. User Conditions narrow that rule further — they never replace it.

In User Conditions, choose Enable if any / all of the following match. "Any" opens the popup as soon as one condition matches; "all" requires every one of them.

Add a condition and pick its type. The rest of the row changes to suit — a device picker for Device, a key box and a value box for Cookie, a country list for User Country.

Add more conditions if you need them. They all sit under the single any/all choice at the top; there is no way to group them or mix "any" and "all" in one rule.

Save, then check the result in a private window. Being logged in changes what you see, and page caching (below) can make a rule look broken when it is not.

An empty User Conditions box means “show to everyone”

Adding no conditions leaves the rule exactly as it was. Targeting only starts applying once there is at least one condition in the list.

Traps worth knowing before you rely on a rule

Most of these are silent. The rule looks right in the editor and behaves wrongly on the site, with nothing to tell you which.

A country is a guess, so never use one to restrict anything

The country is worked out from the address your server sees. A VPN, a proxy or a corporate network changes it, and MaxMind's answer is an estimate even when nobody is trying.

A visitor could once simply name their own address, by sending one extra line with the request. Those headers are now ignored unless the site has declared a reverse proxy in front of it with the XPAC_TRUST_PROXY constant — see MaxMind. If you do set it, make sure nothing can reach the origin server directly, or it is one extra line again.

Either way this is fine for what country targeting is for — a regional offer, a translated banner, a different call to action. It is not fine for anything with consequences: pricing that must differ by region, content you are only licensed to show in some countries, age or legal restrictions. If a rule has to hold, a country condition is not the thing to hold it.

Pages carrying a country are excluded from caching, and are slower for it

The country is the only condition worked out on your server; the rest are worked out in the visitor's own browser. That makes country the only one that full-page caching breaks — the visitor's country is written into the page itself, so a stored copy served to the next visitor hands them the first visitor's country.

Both places that print a country now tell caches not to store the page. They set the DONOTCACHEPAGE constant that WordPress caching plugins read, and send no-cache headers that a reverse proxy or CDN reads, whenever a country was actually resolved for that request.

Read "resolved", not "used". Neither place asks whether a rule needs the country. A popup page resolves it whenever any popup is on the page, and a form page resolves it whenever any form is on the page — so on a site with MaxMind configured and a form in the footer, that is every page with a footer. All of them now go uncached. Nothing happens where no popup and no form renders, and nothing happens when the lookup produced no country, because then there is nothing per-visitor in the page.

Two things follow. Those pages are now uncached by design, so they are slower than the rest of your site — and if that reach is wider than you want, the lever is MaxMind: with no userCountry provider configured, nothing is resolved and nothing is excluded. And a CDN told to cache everything and ignore what your origin says will still serve the wrong country, so exclude those pages there yourself. The symptom, if it happens, is a country popup appearing for obviously wrong people at random, and it will not reproduce while you are logged in.

There is no “where did they come from” condition

This condition was called Referral until 2026-08-17, which was misleading: it has always compared the address of the page the visitor is on, never the page they arrived from. It is now called Page URL, which is what it does, and nothing about what it matches has changed — every rule you already saved behaves exactly as it did.

If you wrote one believing it meant "arrived from Google", it never did. There is currently no way to target by the referring site at all.

A value rule on a cookie that isn't set reads as empty, not as an error

Choosing Cookie (or either storage type), setting the key comparison to exactly match and then testing the value with contains, starts with, ends with or is empty used to throw for any visitor who did not have that item — which is usually most of them — and one broken rule took out every targeted inline popup after it on the page. That was fixed on 2026-08-17: a missing item now reads as an empty value, and a rule that still manages to fail removes only its own popup.

What the rule then means is worth being deliberate about. is empty is true for a visitor who does not have the cookie at all, and contains is false. If you mean "has this cookie, and it says X", write two conditions joined with all: first key: exists, then the value test.

“Mobile” means Android phones and iPhones, and nothing else

The device check reads the browser's own description of itself and looks for two names. An iPad counts as desktop — iPads have described themselves as desktop machines for years. So do Windows tablets, e-readers, and any phone where the visitor has turned on "Request desktop site".

It is not a screen-size test, so it will not follow someone resizing a window either. Treat it as a rough split, not a reliable one.

Targeted content is in the page for everyone

Targeting decides what is shown, not what is sent. A popup's content is delivered to every visitor whose page matched, and then hidden or removed in their browser if the conditions do not match. The conditions themselves — including the exact cookie name and value you are testing for — are in the page source too.

Anyone who looks at the page source can read both. Use targeting to make the right thing appear for the right person; never use it to keep something private, and never put a discount code, an internal note or anything confidential in a popup that only some visitors are meant to see.

What happens when a signal is missing

There are two different failures here and they behave differently, so it is worth keeping them apart.

The condition is not available at all — MaxMind is deactivated, its keys are blank, or User Targeting itself was switched off during an update. Both places then fail closed:

Where the rule isWhat happens to a country rule
A popup display ruleThe rule is dropped and the popup does not appear at all.
A form section behind Conditional LogicThe condition cannot be resolved, so the section is hidden and disabled in the browser, and any answers that reached the server are dropped from the entry.

That is deliberate: a rule that cannot be evaluated is treated as not matched rather than skipped. It means a plugin update can make a country-gated part of a form disappear, so check those sections after one. The rules themselves are not lost — they start working again as soon as the plugin is back.

The condition is available but the lookup produced nothing — MaxMind is configured and answering, but this particular visitor could not be placed: an outage, an exhausted daily quota, an address not in the database, or a request with no visitor address at all such as a scheduled task. The country is then known to be empty rather than unavailable, and the two places diverge:

Where the rule isWhat happens to a country rule
A popup display ruleNeither is nor is not matches. The popup does not appear.
A form section behind Conditional Logicis Germany does not match, but is not Germany does — an empty country is not Germany. The section shows to everyone.

So a rule that restricts something is the dangerous direction on the forms side: "is not Germany" matches every visitor whose country could not be worked out, Germany included. If that matters, do not rely on the rule alone.

Privacy

This plugin does not store anything about your visitors. It writes no cookie, no browser storage item and no database record, and it builds no profile. The cookie and storage conditions only read what something else on your site has already put there.

Two things are still worth knowing:

  • The User Country condition means visitor IP addresses are sent to MaxMind, a third party. That belongs in your privacy policy — see MaxMind for what is sent and what is kept.
  • Reading a cookie is not the same as setting one, but if you build a rule around a cookie that another plugin sets, that cookie is still that plugin's responsibility under your consent banner. Nothing here changes whether it needs consent.

For developers

The package is three PHP files and no blocks. It fires no hooks of its own — every hook it touches is owned by Popups, Forms or the framework — and it registers no REST routes, no post types, no options and no database tables. There is no hook reference page for it because there is nothing to reference. The thin surface is a fact about the plugin, not a gap in this page.

Architecture. Bootstrap is a registry: registerDataProvider($key, $callback, $name) stores a named callback, and editDataProviders() merges the registry into the framework's xpac_data_providers filter (vendor/xpac/plugin/Plugin.php:412). MaxMind is the only registrant today, under the key userCountry. First writer wins — a second registration for the same key is discarded and reported through _doing_it_wrong(), which is visible under WP_DEBUG and silent otherwise. Bootstrap::getPresets() holds the static country and US-state lists. Two components are registered on plugins_loaded, each guarded by a class_exists check on the host plugin's Bootstrap.

Where the decision is made. Browser-side, in every case. The country value is the only thing resolved on the server, and only so it can be printed into the page as a literal for the browser to compare against:

ConsumerServer sideBrowser side
PopupsPopups::getFrontInlineJsData() calls the userCountry callback and prints window.xpacUserTargeting['---user-geo-country---'] before the front handleassets/packages/user-targeting/popups/front/index.js registers an open checker per popup template, and removes untargeted inline popups outright
FormsForms::editInlineJsData() prints the same value into window.xpacForms.dataByProviders; Forms::editProviderBlocksData() also passes the raw callback to Submission for lazy server-side resolutionConditional Logic's view.js reads dataByProviders

The rule matchers live in assets/packages/user-targeting/popups/front/rules/: device.js (via Popups' isMobile(), a /Android|iPhone/i test on the user agent), referral.js (window.location.href, not document.referrer), cookie.js, local-storage.js and session-storage.js (all three delegate to object.js), and user-country.js. index.js builds the matcher map per call and returns false for any type not in it, so an unrecognised or unavailable condition fails closed; checkConditions() likewise returns false for a relation that is not or or and.

Popups storage. Targeting is stored under a userTargeting key on each display rule item, inside the popups option — not in post meta. registerSettingsSchema() extends xpac_popups_popup_settings_schema with relation (or/and, either case) and a dependencies array whose members carry type, compare, deviceType, referralCompare, keyCompare, key, valueCompare, value and userCountry. editPopupConfiguration() then drops, server-side, any display rule whose dependencies contain no recognised type — which is what makes a country rule fail closed when no provider is registered.

Inline popups take a different path: editInlinePopupAttributes() adds the class xpac-popup-user-targeting and a data-user-conditions JSON attribute to the wrapper, and enqueues the front stylesheet whose single rule is display: none. That enqueue runs while the content is printed, which is after wp_head, so the package also registers the handle on xpac_popups_front_style_dependencies: the popups front stylesheet depends on it and WordPress pulls it into the head on any page rendering any popup. A popup first discovered during block render still enqueues both stylesheets late. The browser then either clears the class or calls .remove(). Template popups instead read the whole rule set from the data-settings attribute Popups already prints.

Forms integration. Forms::init() calls Blocks::registerFormBlock('---user-geo-country---', ['is_provider' => true, …]), which makes the country a selectable pseudo-field with fieldType ---provider---. The editor-side consumers of getProviderBlocks() are Conditional Logic (three call sites), User Auth's form settings, and Stripe's payment-element inspector — not Forms itself. Server-side, Submission::getFieldValue() resolves the name from xpac_forms_provider_blocks_data, calling the callback once and memoising it.

Editor data is injected as a frozen, non-writable window.xpacUserTargeting (Popups::getInlineJsData() uses Object.defineProperty plus a recursive Object.freeze). The popup editor build reads it at module evaluation time and only adds the User Country condition when userCountryProvider is non-null and the country preset is non-empty. The read is guarded, so an asset optimiser that moves or drops the inline script omits that one condition rather than throwing and removing the whole User Conditions panel.

Package review notes, including the two ways the country value can be set by the visitor and the cookie-value error described above, are in packages/UserTargeting/ISSUES.md.

On this page