MasVirtual Lead Tracking

Description

Google Ads can optimise for what you report to it, and nothing more. If all you report
is button clicks, that is what it buys you: clicks. The sale that closed three weeks
later, on WhatsApp, by a phone call your CRM never saw, is invisible to the bidding
algorithm.

MasVirtual Lead Tracking closes that loop. It records where every visitor came from, keeps that origin
attached to the lead through your sales funnel, and reports back to Google Ads at each
stage — in the browser for the contact itself, and by offline conversion import for the
qualified lead and the closed sale, which happen weeks later when nobody is on the page
anymore.

The WhatsApp button is part of it because that is where these conversations start, and
because a click that is not measured cannot be attributed. But the button is the input,
not the point: the point is that “campaign arl-transporte produced 3 sales worth
9,600,000” becomes a sentence you can read, and that Google Ads can bid on.

What this version does

  • Captures ad click identifiers (Google, Meta, Microsoft, TikTok), UTM parameters and
    the referrer for every visitor, not just paid ones.
  • Stores first touch and last touch separately, because they rarely match.
  • Renders the floating button, with configurable icon, size, color, shape, position
    and business hours.
  • Measures clicks on any WhatsApp link on the page, including the ones added by
    your theme, an Elementor button or another plugin, and the ones that appear after
    the page loads.
  • Routes the click across several recipients, by rotation, by weight or by schedule,
    and can create the lead already assigned to a sales rep.
  • Different messages per page, per content type or per traffic source: you can
    greet someone who arrived from an ad differently.
  • [mvlt_whatsapp] shortcode and a Gutenberg block.
  • Configurable retention with automatic purging, wired into the WordPress privacy
    tools.
  • Reports conversions to Google Ads: in the browser with gtag for what happens on the
    page, and through CSV import for what happens weeks later, when a sales rep marks
    the sale as won.
  • A diagnostics screen that answers “is this working?” without opening the console,
    with every warning explained and a button to simulate an event.

What it does not do yet

The drag-and-drop lead board with columns, and outgoing webhooks to n8n or a CRM, are
coming in the next release. Leads are already stored in full: nothing captured today
is lost when those arrive.

About the reference in the message

MasVirtual Lead Tracking appends a short reference to the prefilled message, like Ref: [#K7M2QF83].
That is what lets you match the message arriving in WhatsApp with the campaign that
produced it.

Know this before you trust the number: between 15% and 30% of users delete the
prefilled text before sending. Those leads cannot be matched. That is a WhatsApp
limitation, not a plugin one, and the only real mitigation is to push a form as the
primary channel when exact attribution matters.

You can also place the reference mid-message using the {ref} placeholder, which
survives partial deletion better than a trailing line does.

Privacy

Click identifiers are personal data under the GDPR in Europe, and under Law 1581 of
2012 in Colombia. Storing them makes the site operator a data controller.

Add a paragraph to your privacy policy along these lines:

When you visit this site we store, for up to 90 days, the advertising identifier
that came with your visit (for example the Google Ads one), the campaign parameters
in the URL, the address of the page you arrived from, and a random identifier for
your browser. We do this to know which campaigns generate real enquiries. If you
contact us on WhatsApp, we associate that data with your message. You may request
access to or deletion of this information by writing to [your contact address].

The plugin reads the Google Consent Mode v2 state and acts accordingly: without
consent it writes no first-party cookie and keeps only the identifiers that do not
depend on cookies.

What it stores in the browser, with consent, is this and nothing else:

  • wl_v — a first-party cookie holding a random browser identifier. The server
    writes it, it is HttpOnly and SameSite=Lax, and it expires after the number of
    days set as the attribution window. No script reads it, neither this plugin nor any
    other on the page.
  • wl_f, wl_l, wl_uid, wl_turno, wl_vp — in localStorage. These are not
    cookies and travel in no request: first touch, last touch, the lead reference, the
    assigned sales rep, and the browser-proposed visitor identifier used only until the
    server issues the wl_v cookie. The proposed value is deliberately named
    differently from the cookie, because they are two separate things.

There are no third-party cookies and no identifiers shared with anyone.

Conversion feed (optional, disabled by default). If you enable it, the plugin
publishes a URL with a random 48-character key so that Google Ads can collect
conversions on its own schedule. That URL serves a CSV containing Google click
identifiers, sale values and conversion action names, and it is readable by anyone who
knows it: Google cannot authenticate against WordPress. It contains no names, phone
numbers or email addresses. The key can be rotated at any time, which invalidates the
previous URL immediately.

External services

The plugin works without contacting any server of ours: it sends no telemetry, phones
nobody home, and requires no account or key. Everything it captures stays in the
site’s own database.

Two third-party services are involved, both by explicit decision of the administrator:

Google tag (gtag.js). Only if you tick “let MasVirtual Lead Tracking load the Google tag” under
Conversions, which ships disabled. It loads
https://www.googletagmanager.com/gtag/js and sends Google Ads the conversion event
with the identifier, label, value and currency you configured, plus the click
identifier Google placed in the URL. If your site already loads the tag through Site
Kit, GTM or a theme snippet, the plugin detects it and does not duplicate it. Google
terms and privacy: https://policies.google.com/terms ·
https://policies.google.com/privacy

WhatsApp. The button and the links in your content point to https://wa.me, the
official WhatsApp redirector. Nothing is sent to Meta from the server: it is the
visitor who navigates there on tap, with the number and message in the URL, exactly as
with any WhatsApp link. WhatsApp terms and privacy:
https://www.whatsapp.com/legal/terms-of-service ·
https://www.whatsapp.com/legal/privacy-policy

About imported conversions

Conversions that happen after the visit — a qualified lead, a closed sale — cannot be
reported from the browser, because the visitor is no longer there. They accumulate and
are uploaded to Google Ads by file.

Every row carries the event time with the site’s UTC offset stated explicitly, and an
order identifier derived from the lead reference. That identifier is what lets you
upload the same file twice without duplicating conversions.

Before writing each row the plugin validates that the conversion is later than the
click, that no more than 90 days have passed, and that the identifier is of a type
Google accepts through this route. Rows that fail are shown on screen with the reason,
never dropped silently: a conversion that disappears without explanation is worse than
an error, because the team believes it was reported.

Rows whose problem can never be fixed — a lead that arrived through an unpaid channel,
a gbraid/wbraid identifier, a conversion already past the 90-day window — are
discarded with their reason recorded in the lead’s history, so the “pending” counter
only ever shows work that still exists. Rows you can still fix, such as an action
missing its Google Ads name, stay pending on purpose.

To automate the upload without a file, GET /wp-json/mvlt/v1/pendientes returns
already-validated conversions and POST /wp-json/mvlt/v1/exportados confirms the
uploads. That is the recommended route with n8n: the Google Ads API PHP library is
heavy and demands OAuth, and it is not worth it below roughly 200 conversions a month.

Screenshots

Installation

  1. Upload the folder to /wp-content/plugins/ or install the ZIP from the admin.
  2. Activate it.
  3. Go to MasVirtual Lead Tracking WhatsApp and add a phone number. That is enough to get going.

If you serve the site behind full-page caching, purge the cache after activating: the stored HTML does not include the button yet.

FAQ

I already have another WhatsApp button plugin. Do they clash?

No, and this case is expected. MasVirtual Lead Tracking detects the other plugin’s links and measures
them without changing them, which is its default behaviour. You can turn off MasVirtual Lead Tracking’s
own floating button and keep only the measurement.

Does it work with full-page caching?

Yes, that is a design requirement. Nothing that depends on the visitor is generated in
PHP: attribution is resolved in the browser and travels over REST.

Why does the contrast warning appear with the factory colors?

Because it is true: white on the WhatsApp green gives 2.0:1 and does not reach the
recommended 3:1. It is the color pair Meta uses in its own interface and the one people
recognise, so it ships as the default knowingly. If you prefer legibility over
familiarity, darken the background.

I uninstalled the plugin. Did I lose my leads?

No, unless you had ticked the deletion checkbox under Settings. Deactivating never
deletes anything.

Reviews

There are no reviews for this plugin.

Contributors & Developers

“MasVirtual Lead Tracking” is open source software. The following people have contributed to this plugin.

Contributors

Changelog

1.3.0

  • New: the bubble now tells you when someone who already wrote to you comes back.
    The lead reference lives in the visitor’s localStorage and never expires, so when
    somebody already in your funnel returned and tapped the button again, the click was
    filed in their record and nothing else happened. No new lead, and the bubble only ever
    looked at leads in the “New” stage created in the last few hours — so the advisor never
    found out that one of their own customers was writing again. From an incognito window
    the same person did show up, because the reference was gone and a second lead was born.
    That asymmetry was the giveaway.
  • Those returns get their own section in the panel, separate from the clicks awaiting
    confirmation. Their record is not touched: the stage and the outcome stay exactly
    where they were. Sending a closed sale back to “New” would erase the work and could
    re-report a conversion that was already reported. It is a notice, not a question.
  • The panel lost its explanatory paragraphs. Two headings — “New” and “Came back” — and
    the rows. An advisor opens this twenty times a day and stops reading the help text after
    the second, so all it did was push the buttons further down.
  • Each return can be dismissed with “Seen”, and comes back if that person taps again.
    The acknowledgement is stored as one more entry in the lead’s history, so the record
    reads “came back”, “seen by Carolina” rather than just showing a final state.
  • The whole interface is now available in English. The plugin’s source strings are
    written in Spanish, so a site running WordPress in English saw a Spanish admin. All 718
    strings are translated and shipped as an en_US catalogue: the dashboard, the funnel,
    the conversion screens, the WhatsApp settings, every warning and every error message.
    Other languages still go through translate.wordpress.org as usual.
  • The acceptance test suites no longer depend on the site’s language. Several of them
    check a warning by looking for a word inside its text, and the moment the English
    catalogue arrived they started failing on an English site — the code was fine, the
    warning appeared, it just said “contrast” where the test looked for “contraste”. A
    suite whose result depends on where it runs cannot tell you whether the code works.
  • Fixed: a method meant for exactly this was dead code. abierto_por_visitante()
    looks for an open lead belonging to the same visitor within a window, and nothing ever
    called it. The second deduplication layer the code documents has never run: the only
    one in effect is the browser reference.

1.2.1

  • Fixed: switching between the board and the list view was a state-changing GET with no
    nonce.
    The vista parameter writes the chosen view to user meta, so loading a crafted
    URL while logged in flipped someone’s preference behind their back. The damage was small —
    their board moved — but a GET that writes without checking where the request came from is
    CSRF regardless, and the links are generated by the plugin, so signing them costs nothing.
    An absent or expired nonce now falls back to the saved view instead of killing the page: a
    stale bookmark is not an attack.
  • Every query now goes through $wpdb->prepare(), including the table names. Fifty-two
    call sites interpolated the table name into the SQL string, which was the only option
    before WordPress 6.2 introduced the %i identifier placeholder. This plugin requires 6.4,
    so there was no reason left to keep doing it. Where the IN (…) list has a variable number
    of placeholders, those are still built with array_fill() — they are generated %d and
    %s tokens, never data.
  • Raw input that ends up in an admin notice is now sanitized where it is stored, not only
    escaped where it is printed. Those notices travel in a transient and are rendered on a
    later request, so between the two the value is stored data. It is also truncated, because
    the point is only to let the person recognise which of their lines failed.
  • The date filters on the leads screen sanitize before validating. fecha() was already a
    strict allow-list — exact YYYY-MM-DD plus checkdate() — but doing it in that order
    keeps the intent visible on the line itself.
  • Fixed: the credit-in-footer test checked a screen that no longer existed. It hardcoded
    the admin screen id, so the 1.2.0 rename left it asserting against a page nobody could
    reach. It now builds the id from Menu::SLUG.
  • Corrected the plugin folder name in the testing instructions, which the rename had left
    pointing at a directory that does not exist.

1.2.0

  • Renamed. The plugin was called “Walead”, a name already used by unrelated commercial
    lead-generation and WhatsApp marketing services with overlapping functionality. It is now
    “MasVirtual Lead Tracking”, which carries the name of the people who build it and describes
    what it does. The rename goes all the way down: text domain, PHP namespace, option and
    table prefixes, REST namespace, CSS classes and the data- attributes you can put on your
    own links. If you were using data-walead-* attributes or the window.walead object,
    they are now data-mvlt-* and window.mvlt.
  • The unconfirmed-clicks bubble can now be dismissed. It shows across the whole admin on
    purpose — an advisor may be writing a post when a click arrives — but there was no way to
    put it away, which is not a reasonable thing to do to someone else’s dashboard. It now has
    a close button, the choice is remembered per user, and dismissing it stops the polling and
    the stylesheet from loading outside this plugin’s own screens. Inside them the bubble stays,
    and the same button brings it back everywhere. To switch it off entirely there is still the
    setting in Settings.
  • The bubble’s polling interval went from 30 to 60 seconds. It runs on every admin screen, so
    at thirty seconds ten advisors with the dashboard open meant 1,200 requests an hour against
    a shared host, and a click attended one minute later changes nothing.
  • Fixed: the dismissal endpoint would never have existed. Its route was registered from
    the bubble’s own class, which is only instantiated under is_admin() — and a request to
    /wp-json is not. It is now registered alongside the other REST routes, the one place that
    runs in both contexts.
  • The admin menu label is shortened to “Lead Tracking” so it does not wrap onto two lines in
    the sidebar. The page title keeps the full name.
  • The plugin’s own uninstall now removes the bubble’s per-user preference unconditionally,
    outside the “delete my data” checkbox. That checkbox exists to protect commercial history,
    and an interface flag is not that — leaving it behind would orphan one usermeta row per
    advisor.

1.1.1

  • Fixed: in the Canvas view the header count and the board disagreed. Filtering by
    outcome said “6 leads” while the board still drew all 28. The outcome filter now
    reaches the cards: wins and losses are spread across several stages, so filtering by
    outcome trims the board instead of emptying it, and “open opportunities only” is the
    query a coordinator runs most. The stage dropdown, which genuinely does not belong in
    a board whose columns are the stages, is no longer rendered in that view, and the
    header stopped applying it. A control that promises and does not deliver is worse than
    an absent one, because it teaches you to distrust the rest.
  • Fixed: the filter dropdowns could show a filter that was not applied. Browsers
    restore select values on reload and on back-navigation, so the screen could read
    “Lost” while the query filtered nothing — and the only hint was the absence of the
    “Clear filters” link, which nobody looks at. Reported twice as “the filter is broken”,
    and both times it was this. The filter form now declares autocomplete="off", so what
    the controls show is always what was applied.

1.1.0

  • Fixed: the export queue accumulated conversions that could never be sent. A lead
    that arrived through organic search, direct traffic or Meta never gets a gclid, but
    a row was queued for it anyway and re-evaluated on every single export, forever. The
    “pending export” counter kept a number that matched no real work — the worst kind of
    counter, the one that teaches you to ignore it. Rows whose problem is irreversible
    are now discarded with their reason, and each one leaves a record in its lead’s
    history. Rows that a human can still fix — a missing Google Ads action name, a
    conversion time entered wrong — stay pending on purpose, because discarding those
    would take the fix out of the hands of the person who can make it.
  • The localStorage key holding the browser-proposed visitor identifier is now
    wl_vp instead of sharing the wl_v name with the first-party cookie. They were
    always two different things — the cookie is the identity the server decides, and this
    is only a proposal — but the shared name made the privacy section look like it
    contradicted what an auditor sees in the inspector.
  • Minimum PHP lowered to 8.0. Nothing in the codebase used 8.1 syntax; the requirement
    was conservative and blocked installation on hosts that are still on 8.0.
  • The readme is now in English, as the plugin directory requires. Spanish and other
    translations go through translate.wordpress.org.
  • Fixed: a PHP notice on every admin screen under WordPress 6.7 and later. The
    unconfirmed-clicks notice decided whether to register itself during plugins_loaded,
    and that question read a plugin option — which pulls in the defaults, which carry
    translated strings. Asking for a translation before init makes WordPress load the
    text domain too early and complain about it. The same call also ran
    current_user_can() before authentication plugins had finished registering, which is
    an answer worth not trusting. Both checks now happen inside the hooks that render the
    bubble, which run well after init.
  • Removed load_plugin_textdomain(): WordPress has loaded translations for directory
    plugins by itself since 4.6, and calling it before init emits a notice since 6.7.

1.0.9

  • Fixed: the page filter did nothing in the Canvas view. It was wired to the PHP
    listing but not to the canvas, which loads its cards over REST and never sent the
    parameter. The symptom was misleading: the header count did change — PHP computes
    that — and the cards did not.
  • The landing page, in the lead’s attribution. The card now tells the whole story:
    “arrived on /afiliacion-transporte/ with campaign arl-transporte and tapped the
    button on the home page”. Those are two different questions and until now only one
    could be answered.
  • URLs are shown stripped of gclid and campaign parameters, with the original in the
    title and a link to the real page. Normalised with the same function that groups the
    report, so what a card says matches the dashboard table.

1.0.8

  • Which page each person tapped the button on, which is not the same as where they
    came in. Someone can land on a campaign page, not be convinced, browse to the home
    page and write from there: looking only at the channel, that landing page looks like
    it works.
  • “Pages that generate clicks” table on the dashboard, with clicks, closed deals,
    close rate and value. All four columns together because “clicks” alone misleads: a
    page with high volume and zero closes is exactly the one to fix.
  • Page filter on the Leads screen, populated automatically with the pages that have
    received clicks in the last 90 days.
  • The click page in the lead panel, visible and timestamped, because it is context a
    sales rep uses when replying.
  • Works retroactively: the data has travelled in the event log since the first
    version, so the table fills with everything captured to date without any migration.
  • URLs are normalised before grouping. Without that, every Google Ads click arrives with
    its own gclid and twenty clicks on the same page would look like twenty pages.

1.0.7

  • Date range and sales rep filters on the Leads screen, in both views. Previously
    you could only filter by channel, stage, outcome and search.
  • Dates are interpreted as whole days in the site’s timezone, not UTC: in Colombia
    “today” starts at 05:00 UTC, and without the conversion filtering by today left out
    the small-hours leads and included last night’s.
  • “Unassigned” is an option in the sales rep filter, usually a coordinator’s most
    useful query.
  • A sales rep cannot request another’s leads by changing the parameter: their own filter
    is applied after reading it.
  • A link to clear the filters, visible only when some are set.

1.0.6

  • Unconfirmed clicks notice in the admin bar. A counter of clicks awaiting a reply,
    and a panel where each is resolved with “Yes, they wrote” or “No, they didn’t”. This
    fixes the weakest link in the module: nothing can detect that someone wrote on
    WhatsApp — a person records that — and if they don’t, the “Wrote” stage stays empty
    and there are no conversions to report to Google Ads.
  • It appears throughout the WordPress admin, not only on the plugin’s screens: a sales
    rep may be anywhere when a click arrives.
  • “Yes” moves the stage to Wrote and leaves the opportunity open. “No” marks the outcome
    as discarded without touching the stage, because that click was never a conversation.
    Both are reversible from the lead card.
  • It only asks about clicks from the last 48 hours, configurable. Without a limit it
    would ask for confirmation of clicks from weeks ago that nobody remembers.
  • A sales rep only confirms their own. It can be switched off under Settings.
  • It is a floating bubble with its own number, not a screen notice: it grows on its
    own every 30 seconds without reloading, gives a small jump when it rises, and the
    number also appears in the tab title — which is the only thing visible when the rep is
    on another tab. At zero it stays visible but dimmed, in grey.
  • Each row shows the exact time of the click next to the relative one, in the site’s
    timezone, so the rep can compare it against their chat clock. With the day included
    when it is not today.
  • Newest always on top. If a click arrives with the panel open it is not inserted
    automatically: a “1 new click arrived — show” pill appears instead. Inserting it would
    push the list just as someone moves the cursor to a button, and in a panel where every
    click writes to the database that is not a detail.
  • The reference is also visible on the board card, monospaced and in amber: it is the
    code the person carries in their message, and the rep compares it at a glance.

1.0.5

  • No REST route of the plugin can be cached. 1.0.3 protected only the feed and was
    too narrow a fix: the problem was the whole namespace. LiteSpeed caches all of
    mvlt/v1, authenticated requests included, and that produced three failures that
    look nothing alike:
  • Moving a lead on the board saved correctly but reverted to its previous stage on
    refresh — the PATCH wrote and the following GET read a frozen list. It looked like
    saving was failing, and it wasn’t.
  • /turno, which distributes sales reps by strict rotation, handed the same rep to
    everybody. That is the worst of the three because nobody would ever have noticed.
  • And the feed one, already known.
  • The lead reference is now visible on the canvas card, monospaced with an amber
    background, as the first item in the header. It is the code the person carries in their
    WhatsApp message: the rep reads it in the chat and finds the matching card without
    opening anything. It is also in the aria-label, for screen reader users.

1.0.4

  • You can now delete a lead from the interface, in the listing, with confirmation and
    only for users who can configure the plugin: a sales rep corrects a lead, they don’t
    make it disappear.
  • A button to delete all the leads created by “Simulate an event” at once, which used
    to pile up and pollute the reports. It appears only if there are any and it goes by the
    fake gclid, so it can never reach a real lead.
  • Fixed: deleting a lead left its conversions orphaned in the queue. The exporter
    reported them on every attempt as “the lead no longer exists”, dragging a problem with
    no solution forward forever. They are now discarded with their reason recorded.

1.0.3

  • Fixed: a page cache could freeze the feed. It was assumed a REST route is never
    cached. LiteSpeed does cache them — it has a “Cache REST API” option that is often on —
    and the effect was the worst possible: Google requested the URL every day and always
    received the same file, usually the empty one from the first request, with the schedule
    showing green and zero conversions imported. Found in production, not in testing.
  • The feed now declares DONOTCACHEPAGE, fires litespeed_control_set_nocache and sends
    X-LiteSpeed-Cache-Control: no-cache, which covers LiteSpeed, WP Rocket, W3 Total
    Cache, WP Super Cache and Comet Cache.
  • Enabling or rotating the key purges the URL, because an already-stored entry is served
    by the web server without executing PHP and no signal arrives in time.
  • An on-screen notice explains how to verify it, because no plugin can guarantee the
    behaviour of the host’s cache.

1.0.2

  • The MV Gclid Store importer now also runs from MasVirtual Lead Tracking Settings, with the full
    preview visible above the button. Most shared hosts give no WP-CLI access, and a
    migration that can only be run from a terminal is a migration that does not get run.
  • The preview is the real run in dry mode — the same path and the same decisions, without
    writing — and not a separate simulation that might not match what happens when you
    press the button.
  • The block only appears if something is left to import, and disappears when it isn’t.

1.0.1

  • Importer from MV Gclid Store. wp mvlt importar-mv brings the leads from the
    previous setup preserving their state. The single estado field, which mixed funnel
    position and outcome, is split into MasVirtual Lead Tracking’s stage/outcome pair. It also brings the
    gclid as first touch and respects the original click time, without which imported leads
    would be neither exportable nor useful for any report by date. It has --en-seco, it is
    idempotent, and it deliberately does not queue conversions: the ones already reported
    from the previous setup would be duplicated under a different Order ID.

1.0.0

  • Scheduled feed for Google Ads. A keyed URL that Google fetches on its own schedule,
    from Goals Conversions Uploads Schedules, with HTTPS as the source. It supports
    the optional username and password Google offers. Disabled by default; the key is
    rotated or switched off in one click.
  • File format corrected against Google’s specification. First row
    Parameters:TimeZone, headers without quotes, and the UTC offset in four digits. The
    time was also not being written in the right zone: it was formatted in UTC with the
    local offset appended, which shifted every conversion by five hours in Colombia and
    moved small-hours ones to the wrong day.
  • Consent columns Ad User Data and Ad Personalization, required by the EU user
    consent policy.
  • The visitor cookie is written by the server, not by document.cookie. A cookie set
    over HTTP does not fall under the seven-day cap Safari applies to anything a script
    writes, so the attribution window is respected on iOS. And the server recognises a
    returning visitor even if the browser lost localStorage, which previously counted them
    as a new visitor on every load.
  • Exported conversions leave a record on the lead card. Previously the history said
    “queued for export” forever, even months after Google had received it.

0.1.0

  • First release: attribution capture, the complete WhatsApp module, Google Ads conversions
    with diagnostics and export, tables, roles, privacy and purging.