Ghizo Labs
How the plugin works, how to set it up, and what to check when something looks wrong.
It records how a visitor found the site, keeps that record for as long as they keep coming back, and attaches it to whatever they submit. The point is to answer one question: which campaign produced this lead?
Two records are kept per visitor, and they are not the same thing:
A single visit sets both to the same values. They only differ once somebody comes back through a different route.
Everything is captured and stored in the browser. The plugin reads the URL and the referrer with JavaScript and writes the result to a first party cookie. No cookie is ever set by PHP, and nothing about a visitor is ever printed into the HTML.
That is what makes it safe behind a full page cache, a caching plugin, or a CDN. Two different visitors are served byte identical HTML, so there is nothing to go stale and nothing that can leak from one person to another.
The server only reads the cookie at the moment of conversion: a form submission
or a checkout. Both of those are POST requests, which are never
served from a cache, so the browser sends its cookies along with them.
The Gravity Forms screen adds hidden fields to a form in bulk and removes them the same way. Each field is tagged with a token, and the plugin fills it in the browser before the form is submitted. Editors never have to touch them.
There is also a server side safety net. If the browser could not fill a field, for example because JavaScript was blocked, the stored values are still in the cookie sent with the submission, so they are copied in just before the entry is saved. A submitted value is never overwritten.
A token is a touch prefix plus a stored key: first_source,
last_campaign, first_gclid. Every key listed under
What gets captured exists twice, once per touch. The
full list with descriptions is on the Status screen.
To wire a field by hand instead, set the token as the field's parameter name in Gravity Forms and turn on dynamic population.
Contact Form 7 stores nothing by default: a submission becomes an email and then it is gone. So the place attribution has to reach is the mail template rather than a saved entry.
Write any token in the mail template with a wpat_ prefix. It is
resolved when the mail is sent, from the values that arrived with the
submission.
Source: [wpat_last_source]
Campaign: [wpat_first_campaign]
Channel: [wpat_last_channel]
Every token on the Status screen works this way, so
[wpat_first_gclid], [wpat_last_landing_page] and a
custom parameter's token all behave the same.
Add this to the form template. The tag's own name is the token:
[wpat_hidden last_source]
The submitted key is then the token itself, so [last_source]
works in the mail like any other field. The input is rendered empty and filled
in the browser, exactly as a Gravity Forms field is, which is what keeps the
page cacheable. A name that is not a token renders no input at all, so a typo
leaves nothing behind quietly posting an empty value.
The safety net covers these fields too. Any attribution field that arrives empty is filled from the cookie before the mail is built, controlled by Fill empty fields on submission in the settings, and a value the visitor actually submitted is never overwritten.
Write a token as a smart tag, anywhere WPForms accepts text: a notification, a confirmation, or a stored entry on WPForms Pro.
{wpat_last_source}
{wpat_first_campaign}
Every token has one, prefixed with wpat_.
Smart tags rather than a hidden field, because of what the free version can actually do: WPForms' Hidden Field is a Pro only field and renders nothing on the free version, and WPForms Lite stores no entries at all, so a notification email is the only place a value can land without Pro.
Write a token as a smart code, anywhere Fluent Forms accepts text: an email notification, a confirmation message, or a webhook body.
{wpat.last_source}
{wpat.first_campaign}
Every token has one, inside the wpat group. A code that is not one
of ours is handed straight back, so anything belonging to another plugin
resolves exactly as it did before.
To keep the value on the entry rather than only in the email, add a Hidden Field
and set its name to the token, last_source. It is submitted empty
and filled on the server before the entry is stored, which is what keeps the page
cacheable.
Write a token as a merge tag, in a notification, in an action's settings, or as a field's default value.
{wpat:last_source}
{wpat:first_campaign}
Every token has one, inside the wpat group.
For a value that reaches the submission itself, add a Hidden field, set its key
to the token, last_source, and type the matching merge tag as the
default value. Ninja Forms' merge tag picker only offers calculation tags on that
field, so ours has to be typed by hand rather than picked from the list. It
resolves the same way.
The safety net covers these fields too. Any attribution field that arrives empty is filled from the cookie before the submission is processed, controlled by Fill empty fields on submission in the settings.
Forms are a Pro feature, so none of this applies to Elementor's free version.
Write a token in the form's email message, in the same square bracket style Elementor uses for its own fields:
Source: [wpat_last_source]
Campaign: [wpat_first_campaign]
Elementor's own [field id="..."] references are left alone, so the
two sit together in one message without interfering.
For a value in the stored submission rather than only in the email, add a Hidden
field and set its ID to the token, last_source. The plugin fills it
from the cookie when it arrives empty.
One thing worth being straight about: this integration has not been run against a live Elementor Pro install, because Elementor Pro is a paid product and we do not hold a licence for it. It is built against Elementor's documented hooks and is covered by tests, but tests can only prove our half. The email tokens use a documented filter and are the reliable part. Filling a hidden field depends on modifying the submission record, which Elementor does not document, so the code checks whether that is possible before attempting it and does nothing at all when it is not. The worst case is a hidden field that stays empty, not a form that breaks. If you use Elementor Pro, telling us which of the two you see would be a real help.
With WooCommerce active, the visitor's first and last touch are copied onto each order when it is placed, through the classic checkout or the checkout block. Open the order and the Attribution panel shows both.
The order keeps its own copy, so the attribution stays after the cookie expires. An order created in the admin is never attributed, so a phone order does not inherit the campaign the administrator happened to click.
| Group | Keys | From |
|---|---|---|
| Campaign | source, medium, campaign, term, content |
The matching utm_* parameter |
| Campaign detail | campaign_id, source_platform, creative_format, marketing_tactic |
The newer GA4 parameters: utm_id and friends |
| Ad click IDs | gclid, gbraid, wbraid, fbclid, msclkid |
The query parameter of the same name |
| Channel | channel |
Worked out by the plugin, not read from the URL |
| Context | landing_page, referrer, timestamp |
The visit itself |
Any other query parameter can be added under Settings, and is then captured and exposed as a token exactly like the built in ones.
Every visit is given a channel, so a report can group traffic without needing every campaign to have been tagged perfectly. The possible values are Paid Search, Paid Social, Organic Search, Organic Social, Email, Referral, Direct and Other Campaign.
Explicit campaign data always wins over a guess. If the URL
says utm_medium=cpc, that is what is used. Only when a visit
carries no campaign data at all does the plugin fall back to inferring a
channel from the referrer, and an advertising click ID on its own is enough to
classify a visit as paid.
Attribution → Status shows what is running, what your own browser has stored right now, and the full token reference. It is read on the server from the cookie your browser sent, so it is the real stored value rather than a rendering of it.
Turn it on from Attribution → Status with the Enable
debug mode button, or add ?wpat_debug=1 to any front end
URL. Either one opens a panel showing what was captured, which channel it was
classified as, and what happened on that page view.
?wpat_debug=0, the Status screen's own toggle, and the panel's
Stop debugging button all turn it off, because all three
read and write the same local storage flag. Once on, it stays on across page
views, and across closing the browser, until one of those actually turns it
off; removing ?wpat_debug=1 from the address bar does not.
Access is administrators only. To test from a device you are not logged in on, turn on Allow the debug panel for logged out visitors in the settings, then turn it off again when you are done.
JavaScript did not run, or cookies are blocked. Open the browser console on a front end page and check for an error from another script: one failing script can stop the ones after it.
Check that Fill empty fields on submission is on in the settings, which is the server side safety net. If the field was added by hand, check that its parameter name is exactly a token from the Status screen and that dynamic population is enabled on the field.
Usually correct rather than broken. A visit with no campaign parameters and no external referrer is direct. Confirm with a test visit: if a tagged URL classifies properly, capture is working.
The other common cause is a redirect that drops the query string. If your ads
point at a URL that redirects, make sure the redirect preserves
utm_* parameters, or the campaign is gone before the page loads.
First touch is only written when there is nothing stored. If it keeps moving, the cookie is being cleared between visits: check for a cookie consent tool removing it, or a privacy extension.
Expected for an order created in the admin or a renewal. For an order placed through the checkout, the visitor had nothing stored at that moment, most often because JavaScript was blocked for them.
Set-Cookie response header, which is not subject
to that cap. That request goes to a small endpoint that is never part of the
cached page, so nothing about page caching changes, and a slow or failed
request simply leaves the seven day cookie in place until the next page view
tries again. Every other browser already honours the full setting from the
cookie alone.
// One value. The touch comes second and defaults to last.
$source = wpat_value( 'source' );
$origin = wpat_value( 'source', 'first' );
// A whole touch, as key => value.
$last = wpat_touch( 'last' );
// Both touches at once.
$record = wpat_get();
// A channel value turned into something worth showing a person.
echo esc_html( wpat_channel_label( $last['channel'] ?? '' ) );
These read the cookie on the current request, so only call them somewhere that is not cached: a form handler, a checkout hook, an AJAX response. Calling them while rendering a cacheable page would bake one visitor's data into a page served to everybody.
| Hook | What it is for |
|---|---|
wpat_tracked_params |
The query parameters captured on every page view |
wpat_channel_rules |
The rules that classify a visit into a channel |
wpat_script_config |
The configuration handed to the front end script |
wpat_debug_config |
The debug panel's own configuration |
wpat_debug_allowed |
Whether this request may see the debug panel |
wpat_feature_enabled |
Switches one capability off for a build |
wpat_register_features |
Action: add or replace a feature |
wpat_loaded |
Action: fires once every feature has registered |
Every part of the plugin is a feature that registers no hooks at all when it is off, so removing one costs nothing at runtime. The Status screen lists them.
add_filter( 'wpat_feature_enabled', function ( bool $enabled, string $id ): bool {
return 'gravity-forms' === $id ? false : $enabled;
}, 10, 2 );
wpat_settings, one versioned option.wpat_attribution, one first party cookie, holding both touches
and nothing else.
_wpat_attribution for the whole record, plus eight
flat _wpat_* rows for the values that get filtered and
grouped.
Deactivating the plugin changes nothing. Deleting it removes its option. Values already written into form entries belong to those records and are left alone.