Funnelish is great at what it’s built for — fast landing pages, order forms, and one-click upsell flows that convert. What it’s not built for is giving you clean, trustworthy tracking out of the box. And if you’re spending real money on Meta, TikTok, or Google Ads to drive traffic into a Funnelish funnel, that gap will cost you far more than the funnel earns you.
I’ve lost count of how many times I’ve been brought in to “fix the numbers” on a Funnelish setup, only to find the same handful of problems every single time: sessions breaking the moment a user hits the checkout, upsell revenue vanishing from the ad platforms, and GA4 quietly filing most purchases under “Direct / None.”
This isn’t a Funnelish problem, exactly. It’s a how Funnelish is usually set up problem. And it’s fixable. Here’s how I approach it.
First, understand why it breaks
Before touching a single tag, it helps to understand why native Funnelish tracking falls apart. Three things are almost always going on.
Your session dies at the domain boundary. Most Funnelish funnels route users from a primary domain to a checkout subdomain — say, from brand.com to checkout.brand.com. Browsers treat those as separate cookie contexts. Without an explicit cross-domain linker, GA4 sees the checkout visit as a brand-new session with no referrer, so it labels it “Direct.” Every paid click that converts on the checkout page gets stripped of the ad that earned it.
Your upsells are invisible. One-click post-purchase upsells fire after the main transaction, usually through a fast redirect. Native pixel scripts often don’t get a chance to execute during that transition, so the upsell revenue never reaches Triple Whale, Meta, or GA4. On a funnel with strong upsell take rates, that can be a third of your revenue simply missing from the platforms making your bidding decisions.
You’re double-counting — or blocking yourself. When native Funnelish pixel apps run alongside tags you’ve placed manually, the same purchase can fire twice, inflating ROAS. And native third-party scripts that load without first-party routing get eaten by ad blockers and Safari’s tracking prevention before they capture anything.
Any one of these quietly corrupts your data. Together, they make your dashboards actively misleading — which is worse than having no data at all, because you’ll make confident decisions on numbers that are wrong.
The fix: one central tracking layer, not five scattered scripts
The core principle I work from is simple: everything runs through one Google Tag Manager container, and nothing runs natively inside Funnelish.
Instead of letting Funnelish’s built-in pixel apps, a hardcoded GA4 snippet, and a Triple Whale integration all fire independently, you route every event through a single GTM container that you control. One place to see what’s firing. One place to fix it when it breaks. No duplicate tags fighting each other.
Here’s the order I build it in.
1. Consent first, always
Set up your consent framework before any tracking tag. I use Consent Mode v2 with a proper CMP, defaulting ad_storage, analytics_storage, ad_user_data, and ad_personalization to denied until the user chooses. This tag fires on the Consent Initialization trigger so it runs before everything else, and every downstream tag respects it.
Skipping this or bolting it on later is how you end up compliant on your main site but wide open on your Funnelish subdomains — which regulators (and increasingly, the browsers themselves) don’t care to distinguish between.
2. Deploy GTM globally across every funnel step
Install your GTM container in the header and body of every Funnelish step — landing page, order form, upsell, thank-you. Funnelish lets you paste this globally in Custom Code and toggle it on across all steps. Consistency here is what makes everything downstream possible.
3. Build a custom event listener — this is the real work
Funnelish handles order submissions, step transitions, and upsell clicks dynamically in JavaScript. Standard page-load triggers in GTM will miss all of it. So the heart of a good Funnelish setup is a custom listener that catches those dynamic actions and pushes clean, structured events into the dataLayer.
The listener needs to do a few specific things well:
- Hook into Funnelish’s own client-side events (the purchase and upsell_accept callbacks) so you catch completions the instant they happen, across every payment method.
- Generate a stable transaction key in session storage, so that even when Funnelish doesn’t hand you a clean order ID during a fast redirect, you have something reliable to deduplicate against.
- Push the upsell as its own distinct event — separate from the main purchase — so upsell revenue is captured rather than swallowed.
- Collect normalized first-party user data (email, phone, and the _fbp / _fbc cookies) to feed match quality for Triple Whale and your CAPI setup.
This is the part people try to shortcut, and it’s exactly the part that determines whether the whole thing works.
Here’s the listener I use as a starting point. Create it as a Custom HTML tag in GTM, fire it on the Consent Initialization – All Pages trigger, and adjust the selectors to match your funnel’s markup:
<script>
(function () {
// —- helpers ————————————————–
function cookie(name) {
var m = document.cookie.match(‘(?:^|; )’ + name + ‘=([^;]*)’);
return m ? decodeURIComponent(m[1]) : ”;
}
// A stable per-purchase key so we can dedupe even when Funnelish
// doesn’t hand us a clean order ID during a fast redirect.
function txnKey() {
var k = sessionStorage.getItem(‘fnl_txn’);
if (!k) {
k = ‘fnl_’ + Date.now() + ‘_’ + Math.floor(Math.random() * 1e6);
sessionStorage.setItem(‘fnl_txn’, k);
}
return k;
}
// Guard so the same event never pushes twice in one session.
function firedOnce(tag) {
var key = ‘fnl_fired_’ + tag;
if (sessionStorage.getItem(key)) return true;
sessionStorage.setItem(key, ‘1’);
return false;
}
function firstParty() {
return { fbp: cookie(‘_fbp’), fbc: cookie(‘_fbc’) };
}
window.dataLayer = window.dataLayer || [];
// —- 1. Product view on funnel load —————————
document.addEventListener(‘DOMContentLoaded’, function () {
var p = (window.funnelish && window.funnelish.product) || null;
if (!p) return;
window.dataLayer.push({
event: ‘view_item’,
ecommerce: {
currency: p.currency || ‘USD’,
value: parseFloat(p.price || 0),
items: [{
item_id: String(p.id || p.sku || ‘fnl_item’),
item_name: p.name || document.title,
price: parseFloat(p.price || 0),
quantity: 1
}]
}
});
});
// —- 2. Checkout start (main pay button) ———————-
document.addEventListener(‘click’, function (e) {
var btn = e.target.closest(‘.fnl-pay-btn, .submit-btn, [data-funnelish-submit]’);
if (!btn) return;
var email = (document.querySelector(‘input[type=”email”], input[name=”email”]’) || {}).value || ”;
var phone = (document.querySelector(‘input[type=”tel”], input[name=”phone”]’) || {}).value || ”;
window.dataLayer.push({
event: ‘begin_checkout’,
user_data: { email: email, phone: phone }
});
});
if (typeof window.funnelish === ‘undefined’) return;
// —- 3. Main purchase —————————————–
window.funnelish.on(‘purchase’, function (data) {
if (firedOnce(‘purchase’)) return; // dedupe against double-fire
var id = String(data.order_id || txnKey());
var value = parseFloat(data.total || data.price || 0);
var items = Array.isArray(data.products)
? data.products.map(function (p) {
return {
item_id: String(p.id || p.sku),
item_name: p.name,
price: parseFloat(p.price || 0),
quantity: parseInt(p.quantity || 1, 10)
};
})
: [{
item_id: String(data.product_id || ‘fnl_main’),
item_name: data.product_name || ‘Funnelish Offer’,
price: value,
quantity: 1
}];
window.dataLayer.push({
event: ‘purchase’,
ecommerce: {
transaction_id: id,
value: value,
tax: parseFloat(data.tax || 0),
shipping: parseFloat(data.shipping || 0),
currency: data.currency || ‘USD’,
items: items
},
user_data: {
email: data.email || ”,
phone: data.phone || ”,
first_name: data.first_name || ”,
last_name: data.last_name || ”,
fbp: firstParty().fbp,
fbc: firstParty().fbc
}
});
});
// —- 4. One-click post-purchase upsell ————————
// Pushed as its OWN event with a distinct transaction_id, so the
// upsell can never collide with (or get swallowed by) the main sale.
window.funnelish.on(‘upsell_accept’, function (up) {
var id = String((up.order_id || txnKey()) + ‘_up’);
if (firedOnce(‘upsell_’ + id)) return;
var value = parseFloat(up.price || 0);
window.dataLayer.push({
event: ‘purchase_upsell’,
ecommerce: {
transaction_id: id,
value: value,
currency: up.currency || ‘USD’,
items: [{
item_id: String(up.product_id || ‘fnl_upsell’),
item_name: up.product_name || ‘One-Time Offer’,
price: value,
quantity: 1
}]
}
});
});
})();
</script>
A few things worth calling out about how this is built, because they’re the details that actually matter:
- The firedOnce() guard is what stops the same purchase pushing twice when a native pixel and your GTM tag both trip. Deduplication at the source is far more reliable than trying to untangle double-counted transactions in reporting later.
- txnKey() gives you a fallback order ID. Funnelish doesn’t always hand you a clean ID during a fast redirect, and a purchase event with no transaction_id is a purchase GA4 will happily double-count. The session-storage key guarantees you always have something stable to dedupe against.
- The upsell is a completely separate event (purchase_upsell) with its own _up suffixed ID. This is the single most common thing I see done wrong — bundling the upsell into the main purchase, or dropping it entirely. Keep it distinct and it maps cleanly all the way through to Triple Whale and your CAPI.
- First-party cookies (_fbp, _fbc) travel with the purchase so your match quality holds up on Meta and through server-side.
Treat this as a foundation, not a copy-paste-and-forget. The selectors in step 2 and the shape of the funnelish object will vary with your funnel — always confirm the real event payloads in GTM Preview before you trust it.
4. Configure GA4 with a proper cross-domain linker
With clean events flowing into the dataLayer, GA4 configuration is straightforward: a Google Tag with allow_linker set to true, and your primary domain plus Funnelish subdomains listed in the cross-domain settings. This is what keeps the session alive across the domain boundary — and it’s the single fix that turns “Direct / None” back into properly attributed paid traffic.
Then map your ecommerce events (view_item, begin_checkout, purchase, and the separate upsell event) to GA4 event tags, sourcing data from the dataLayer.
How to know it actually works
A setup you haven’t validated is a setup you don’t have. Before any campaign runs, I test the full chain:
- GTM Preview mode on the live funnel — confirm the listener, GA4 tag, Triple Whale, and Clarity all fire on load.
- Cross-domain check — click from the main domain into checkout and confirm the _gl parameter is riding along in the URL, and that GA4 DebugView keeps a single session ID across the transition.
- A real test purchase, including the upsell — you should see two distinct events in Preview: the main purchase and the purchase_upsell. If you only see one, your upsell tracking is broken.
- Cross-check the dashboards — the test order value should match in Triple Whale’s live stream, and a Clarity recording should exist with your funnel-step tags attached.
If all of that passes, you have a funnel you can actually scale on — because the numbers you’re bidding against are finally real.
